kochab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9a614b6dc35cf6bb101732526b9679f51fedd2f4b48a39737d7eca3161fcdbc9
4
+ data.tar.gz: 055f80b9abb6f3e339bd5fb7949f53b0a83f7c542377b255d8a715205e0789c7
5
+ SHA512:
6
+ metadata.gz: fa6984ae1709b30cc8b0cfecaf09a52c03e493cc47f5f485f04ad6d084e1a3f452e3150ac09e3717eb111db3af397522560410bfacd4f8c11e22df69aee6b26b
7
+ data.tar.gz: 524c84d838078662b7881f26f1ded5b5843a40c80a5fd5aea47d7d88c88638f1dbbdbee170f09470daa95c8fc6ba590ad2b837ab66ffc3918aed0ec975a350ed
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-11
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Kochab
2
+
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.
7
+
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.
11
+
12
+ ## Installation
13
+
14
+ From this checkout:
15
+
16
+ ```sh
17
+ gem build kochab.gemspec
18
+ gem install ./kochab-0.1.0.gem
19
+ ```
20
+
21
+ Or use `gem "kochab", path: "/path/to/checkout"` in your Gemfile.
22
+
23
+ ## Parse and update a setting
24
+
25
+ ```ruby
26
+ require "kochab"
27
+
28
+ text = <<~JSONC
29
+ {
30
+ // Font size in points
31
+ "editor": { "font_size": 12, "theme": "dark" },
32
+ }
33
+ JSONC
34
+
35
+ doc = Kochab.parse(text)
36
+ doc.value # {"editor" => {"font_size" => 12, ...}}
37
+ doc.valid? # true
38
+ doc.errors # []
39
+
40
+ edits = doc.set(["editor", "font_size"], 14)
41
+ updated = Kochab.apply(text, edits)
42
+ raise unless updated == text.sub("12", "14")
43
+ ```
44
+
45
+ Every offset and range is measured in **UTF-8 bytes**, and ranges exclude the
46
+ end offset. Ruby `String#[]` indexes characters: use `String#byteslice(range)`
47
+ to extract source ranges. Input strings are treated as UTF-8 bytes; other
48
+ encodings are not transcoded because that would change their offsets.
49
+
50
+ ## Source queries
51
+
52
+ ```ruby
53
+ range = doc.range_of(["editor", "font_size"])
54
+ text.byteslice(range) # "12"
55
+ doc.key_range_of(["editor", "font_size"]) # includes the key's quotes
56
+ doc.node_at(range.begin).kind # :number
57
+ doc.path_at(range.begin) # ["editor", "font_size"]
58
+ position = doc.utf16_position_at(range.begin) # [line, UTF-16 column], zero-based
59
+ doc.offset_at_utf16_position(*position) # inverse conversion
60
+ ```
61
+
62
+ Array paths use nonnegative integer indices. Missing paths return `nil`.
63
+ `node_at` descends by binary search through sorted children. Whitespace inside
64
+ a container resolves to that container; offsets outside the root return `nil`.
65
+ UTF-16 conversions reject out-of-range positions, split UTF-8 characters,
66
+ split surrogate pairs, and invalid UTF-8. LF, CRLF, and CR are recognized.
67
+ Positions inside a CRLF delimiter map to the preceding line's end.
68
+
69
+ Nodes expose `kind`, `range`, `key_range`, `value`, `children`,
70
+ `leading_comments`, `trailing_comment`, `parent`, and `key`. Kinds are
71
+ `:object`, `:array`, `:property`, `:string`, `:number`, `:boolean`, and `:null`.
72
+ Property nodes have one value child; a missing value is a zero-length `:null`
73
+ node. Containers' `value` fields contain their Ruby Hash or Array values.
74
+ Treat the tree and its values as read-only snapshots. `doc.text` is frozen.
75
+
76
+ ## Recovery and strict mode
77
+
78
+ The default parser consumes the complete input and reports syntax problems in
79
+ `doc.errors`, including invalid UTF-8. It returns the best available value:
80
+
81
+ ```ruby
82
+ doc = Kochab.parse('{"a" 1, "b": , "c": 3}')
83
+ doc.value # {"a" => 1, "b" => nil, "c" => 3}
84
+ doc.errors.map(&:code) # [:expected_colon, :expected_value]
85
+ doc.valid? # false
86
+
87
+ Kochab.parse('{"a":1}', strict: true) # standard JSON only
88
+ ```
89
+
90
+ `strict: true` raises `Kochab::ParseError` for invalid syntax and exposes
91
+ all diagnostics through the exception's `errors`. Comments, a BOM, trailing
92
+ commas, and named non-finite numbers are rejected in strict mode. Duplicate
93
+ keys produce `:duplicate_key` warnings and the last occurrence wins; warnings
94
+ do not make `valid?` false. Diagnostic fields are `range`, `code`, `message`,
95
+ and `severity` (`:error` or `:warning`).
96
+
97
+ | Option | Default | Meaning |
98
+ | --- | --- | --- |
99
+ | `trailing_commas` | `true` | Set to `false` to diagnose trailing commas |
100
+ | `allow_nan` | `false` | Opt into `NaN`, `Infinity`, `-Infinity` literals |
101
+ | `max_depth` | `512` | Maximum nesting depth; configurable from 1 to 512 |
102
+
103
+ Unfinished strings end at a line break or EOF; missing colons and commas are
104
+ diagnosed and recovered; unmatched closing brackets are skipped. Overdeep
105
+ subtrees are skipped iteratively and replaced by `nil`. This bounds the Ruby
106
+ call stack while still consuming the document. Integers retain arbitrary
107
+ precision; floats follow Ruby JSON's overflow/underflow behavior. JSON5 features
108
+ (unquoted keys, single quotes, hexadecimal numbers) are excluded. Wrong
109
+ argument types still raise normal Ruby exceptions.
110
+
111
+ All comments are available as `doc.comments`; each has `range`, `text`, and
112
+ `:line` or `:block` `kind`. Contiguous comments immediately above a node attach
113
+ as `leading_comments`. A same-line comment after a value attaches to that
114
+ value's `trailing_comment`. Comments separated by a blank line, and additional
115
+ comments that cannot occupy the single trailing slot, are `floating_comments`.
116
+ Comment text always retains its original delimiters and bytes.
117
+
118
+ ## Minimal edits
119
+
120
+ ```ruby
121
+ doc.set(["editor", "font_size"], 14) # replace one value
122
+ doc.insert(["editor", "language"], "ja", after: "theme")
123
+ doc.remove(["editor", "theme"]) # delete member + comma
124
+ doc.insert(["recent_files", 0], "/tmp/notes.txt") # insert into an array
125
+ doc.set([], {"new_root" => true}) # replace the root
126
+ ```
127
+
128
+ Each method returns `TextEdit` objects with `offset`, `length`, and `text`.
129
+ `set` changes only the value's byte span and can repair an unfinished value;
130
+ it inserts a missing final member when its parent exists. `insert` rejects an
131
+ existing object key; omitted `after` appends. `remove` removes every duplicate
132
+ occurrence of the requested object key; an absent key is a no-op.
133
+
134
+ Insertion preserves all existing bytes. Removal deletes the requested node
135
+ and its required comma, leaving unrelated whitespace and comments intact.
136
+ Comments inside a removed/replaced subtree belong to that subtree and are
137
+ removed with it. Removing a node can leave blank lines or detached comments.
138
+ `insert` and `remove` require a valid document; `set` can repair a recovered
139
+ node using its recorded value span.
140
+
141
+ Edits use the original snapshot's coordinates. Reparse after applying edits
142
+ before generating further edits. `apply` validates bounds, UTF-8 boundaries,
143
+ replacement encoding, and overlap. Same-offset insertions are concatenated in
144
+ input order; insertions at a replacement's start precede that replacement.
145
+ It builds the output in one pass without mutating the original string.
146
+
147
+ ## Formatting
148
+
149
+ ```ruby
150
+ pretty = Kochab.format(text, indent: 2, keep_blank_lines: 1)
151
+ ```
152
+
153
+ Formatting normalizes indentation and spacing, preserves every comment and
154
+ literal token verbatim (including escaped strings and trailing commas), uses
155
+ the first newline style present in the input, and appends one final newline.
156
+ `keep_blank_lines` limits consecutive blank lines between tokens. Formatting
157
+ rejects invalid documents to avoid discarding incomplete input. Use minimal
158
+ edits when existing whitespace must be preserved exactly.
159
+
160
+ ## Validation and performance
161
+
162
+ ```sh
163
+ bundle install
164
+ bundle exec rake test # complete suite, including fuzz and JSON oracle
165
+ bundle exec rake test:oracle # all 318 pinned upstream JSONTestSuite cases
166
+ bundle exec rake test:fuzz # 200 recovery cases + 5,000 random/mutated inputs
167
+ bundle exec rake isolation
168
+ bundle exec rake bench:assert # Ruby with YJIT; machine-dependent budgets
169
+ ruby -Ilib examples/settings.rb
170
+ ```
171
+
172
+ Tests include all `y_`, `n_`, and `i_` parsing cases from
173
+ [JSONTestSuite](https://github.com/nst/JSONTestSuite), pinned in
174
+ `test/fixtures/json_test_suite/PROVENANCE.md` with its MIT license. Every `y_`
175
+ case must pass and every `n_` case must fail; upstream explicitly permits either
176
+ outcome for `i_` cases. Valid inputs are also checked against `JSON.parse`.
177
+ Recovery/fuzz tests check termination, source preservation, and bounded ranges.
178
+ Edit tests check exact replacement bytes and preservation of unrelated comments.
179
+
180
+ Measured on macOS arm64, Ruby 4.0.0 with YJIT (2026-09-09), seven-sample medians:
181
+
182
+ | Operation | Observed | Budget |
183
+ | --- | ---: | ---: |
184
+ | Parse 10,256-byte JSONC | 1.13 ms | 2 ms |
185
+ | Parse 1,048,594-byte JSONC | 157.5 ms | 200 ms |
186
+ | Query `node_at` | < 1 µs | 10 µs |
187
+ | Generate a `set` edit | 2 µs | 1 ms |
188
+
189
+ The corpus contains nested settings, UTF-8 strings, and one comment per setting.
190
+ Run `bench/benchmark.rb` on your deployment machine; these are measurements,
191
+ not timing guarantees. CI uses three times these limits on shared runners.
192
+ Parsing uses memory proportional to input size. Queries
193
+ cost O(depth × log siblings). Object path lookup scans members, whereas
194
+ byte-position queries use binary search.
195
+
196
+ CI tests Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 on Linux, macOS, and Windows. Performance
197
+ budgets run separately on Linux with Ruby 4.0 and YJIT. Built-gem installation
198
+ and the example are smoke-tested in CI.
199
+
200
+ ## License
201
+
202
+ MIT; see [LICENSE.txt](LICENSE.txt). Vendored test data retains its original
203
+ MIT notice. The implementation does not depend on `jsonc-parser`; its
204
+ [source API](https://github.com/microsoft/node-jsonc-parser) is a reference for
205
+ the editor-facing behavior.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kochab"
4
+
5
+ source = <<~JSONC
6
+ {
7
+ // Size in points; this comment survives the update.
8
+ "editor": { "font_size": 12, "theme": "dark" },
9
+ }
10
+ JSONC
11
+
12
+ document = Kochab.parse(source)
13
+ updated = Kochab.apply(source, document.set(["editor", "font_size"], 14))
14
+ puts updated
15
+ raise "Unexpected edit" unless updated == source.sub("12", "14")
16
+
17
+ document = Kochab.parse(updated)
18
+ puts "Font size bytes: #{document.range_of(['editor', 'font_size'])}"
19
+ puts "LSP position: #{document.utf16_position_at(document.range_of(['editor', 'font_size']).begin)}"
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kochab
4
+ # A parsed source snapshot. Edits refer to this snapshot and do not mutate it.
5
+ class Document
6
+ attr_reader :text, :root, :errors, :comments, :floating_comments
7
+
8
+ def initialize(text, root, errors, comments, tokens)
9
+ @text, @root, @errors, @comments, @tokens = text, root, errors, comments, tokens
10
+ @floating_comments = []
11
+ end
12
+
13
+ def value
14
+ @root&.value
15
+ end
16
+
17
+ def valid?
18
+ @errors.none? { |error| error.severity == :error }
19
+ end
20
+
21
+ # Deepest node covering a byte offset. Whitespace belongs to its container.
22
+ def node_at(offset)
23
+ return nil unless offset.is_a?(Integer) && @root && @root.range.cover?(offset)
24
+
25
+ current = @root
26
+ loop do
27
+ child = current.children.bsearch { |entry| entry.range.end > offset }
28
+ return current unless child&.range&.cover?(offset)
29
+
30
+ current = child
31
+ end
32
+ end
33
+
34
+ def path_at(offset)
35
+ current = node_at(offset)
36
+ return nil unless current
37
+
38
+ path = []
39
+ while current.parent
40
+ path << current.key if current.kind == :property || current.parent.kind == :array
41
+ current = current.parent
42
+ end
43
+ path.reverse
44
+ end
45
+
46
+ def range_of(path)
47
+ entry = find_node(path)
48
+ entry = entry.children.first if entry&.kind == :property
49
+ entry&.range
50
+ end
51
+
52
+ def key_range_of(path)
53
+ find_node(path)&.key_range
54
+ end
55
+
56
+ # LSP positions count UTF-16 code units, with zero-based lines/columns.
57
+ def utf16_position_at(offset)
58
+ raise EncodingError, "LSP positions require valid UTF-8" unless @text.valid_encoding?
59
+ check_byte_offset(offset)
60
+ line = (line_starts.bsearch_index { |start| start > offset } || line_starts.length) - 1
61
+ prefix = @text.byteslice(line_starts[line]...offset).scrub.sub(/[\r\n]+\z/, "")
62
+ [line, prefix.encode(Encoding::UTF_16LE).bytesize / 2]
63
+ end
64
+
65
+ def offset_at_utf16_position(line, character)
66
+ raise EncodingError, "LSP positions require valid UTF-8" unless @text.valid_encoding?
67
+ unless line.is_a?(Integer) && character.is_a?(Integer) && line >= 0 && character >= 0 && line < line_starts.length
68
+ raise RangeError, "UTF-16 position is outside the document"
69
+ end
70
+ start = line_starts[line]
71
+ finish = line_starts[line + 1] || @text.bytesize
72
+ content = @text.byteslice(start...finish).scrub.sub(/[\r\n]+\z/, "")
73
+ units, bytes = 0, 0
74
+ content.each_char do |char|
75
+ break if units == character
76
+
77
+ units += char.ord > 0xffff ? 2 : 1
78
+ raise RangeError, "UTF-16 position splits a surrogate pair" if units > character
79
+ bytes += char.bytesize
80
+ end
81
+ raise RangeError, "UTF-16 position is past the line end" unless units == character
82
+
83
+ start + bytes
84
+ end
85
+
86
+ private
87
+
88
+ def check_byte_offset(offset)
89
+ unless offset.is_a?(Integer) && (0..@text.bytesize).cover?(offset)
90
+ raise RangeError, "Byte offset is outside the document"
91
+ end
92
+ byte = @text.getbyte(offset)
93
+ raise RangeError, "Byte offset splits a UTF-8 character" if @text.valid_encoding? && byte && byte & 0xc0 == 0x80
94
+ end
95
+
96
+ def line_starts
97
+ @line_starts ||= begin
98
+ result = [0]
99
+ @text.b.to_enum(:scan, /\r\n|\r|\n/n).each { result << Regexp.last_match.end(0) }
100
+ result
101
+ end
102
+ end
103
+
104
+ def find_node(path)
105
+ raise TypeError, "path must be an Array" unless path.is_a?(Array)
106
+
107
+ path.reduce(@root) do |parent, key|
108
+ parent = parent.children.first if parent&.kind == :property
109
+ case parent&.kind
110
+ when :object
111
+ raise TypeError, "Object path keys must be Strings" unless key.is_a?(String)
112
+ parent.children.reverse_each.find { |child| child.key == key }
113
+ when :array
114
+ raise TypeError, "Array path indices must be nonnegative Integers" unless key.is_a?(Integer) && key >= 0
115
+ parent.children[key]
116
+ else
117
+ nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kochab
4
+ class Document
5
+ # Replace the value at path, or insert a missing final object member.
6
+ # Only the value's bytes are replaced; adjacent trivia is never included.
7
+ def set(path, value)
8
+ entry = find_node(path)
9
+ return insert(path, value) unless entry
10
+
11
+ entry = entry.children.first if entry.kind == :property
12
+ [TextEdit.new(offset: entry.range.begin, length: entry.range.size, text: JSON.generate(value))]
13
+ end
14
+
15
+ # Remove all occurrences of a member, or one array element. Comments outside
16
+ # the removed node survive, as do all whitespace and unrelated tokens.
17
+ def remove(path)
18
+ raise ArgumentError, "Cannot remove the document root; use set([], nil)" if path == []
19
+ entry = find_node(path)
20
+ return [] unless entry
21
+ raise ParseError, errors unless valid?
22
+
23
+ entries = entry.kind == :property ? entry.parent.children.select { |child| child.key == entry.key } : [entry]
24
+ edits = entries.flat_map do |child|
25
+ result = [TextEdit.new(offset: child.range.begin, length: child.range.size, text: "")]
26
+ following = token_after(child.range.end)
27
+ preceding = token_before(child.range.begin)
28
+ comma = following&.kind == :comma ? following : (preceding if preceding&.kind == :comma)
29
+ result << TextEdit.new(offset: comma.range.begin, length: 1, text: "") if comma
30
+ result
31
+ end
32
+ edits.uniq { |edit| [edit.offset, edit.length] }
33
+ end
34
+
35
+ # Insert a new object key (optionally after another key) or an array index.
36
+ def insert(path, value, after: nil)
37
+ raise TypeError, "path must be a nonempty Array" unless path.is_a?(Array) && !path.empty?
38
+ raise ParseError, errors unless valid?
39
+
40
+ parent = find_node(path[0...-1])
41
+ parent = parent.children.first if parent&.kind == :property
42
+ key = path.last
43
+ index = insertion_index(parent, key, after)
44
+ payload = JSON.generate(value)
45
+ payload = "#{JSON.generate(key)}: #{payload}" if parent.kind == :object
46
+
47
+ if (following = parent.children[index])
48
+ offset = following.leading_comments.first&.range&.begin || following.range.begin
49
+ prefix = line_prefix(offset)
50
+ separator = prefix.match?(/\A[ \t]*\z/) ? eol + prefix : " "
51
+ return [TextEdit.new(offset: offset, length: 0, text: payload + "," + separator)]
52
+ end
53
+ append_entry(parent, payload)
54
+ end
55
+
56
+ # Pretty-print the token stream without decoding/re-encoding strings or comments.
57
+ def format(indent: 2, keep_blank_lines: 1)
58
+ raise ParseError, errors unless valid?
59
+ unless indent.is_a?(Integer) && indent >= 0 && keep_blank_lines.is_a?(Integer) && keep_blank_lines >= 0
60
+ raise ArgumentError, "indent and keep_blank_lines must be nonnegative Integers"
61
+ end
62
+ events = (@tokens.reject { |token| token.kind == :eof } + @comments).sort_by { |event| event.range.begin }
63
+ output = @text.start_with?("\uFEFF") ? +"\uFEFF" : +""
64
+ depth = 0
65
+ previous = nil
66
+ events.each do |event|
67
+ closing = [:object_end, :array_end].include?(event.kind)
68
+ depth -= 1 if closing
69
+ if previous
70
+ gap = @text.byteslice(previous.range.end...event.range.begin)
71
+ newlines = gap.scan(/\r\n|\r|\n/).length
72
+ separator = separator_between(previous, event, newlines)
73
+ if separator == :newline
74
+ output << eol * (1 + [[newlines - 1, 0].max, keep_blank_lines].min)
75
+ output << " " * (indent * depth)
76
+ elsif separator == :space
77
+ output << " "
78
+ end
79
+ end
80
+ output << @text.byteslice(event.range)
81
+ depth += 1 if [:object, :array].include?(event.kind)
82
+ previous = event
83
+ end
84
+ output << eol
85
+ end
86
+
87
+ private
88
+
89
+ def token_after(offset)
90
+ @tokens.bsearch { |token| token.range.begin >= offset }
91
+ end
92
+
93
+ def token_before(offset)
94
+ index = @tokens.bsearch_index { |token| token.range.begin >= offset }
95
+ @tokens[index - 1] if index && index.positive?
96
+ end
97
+
98
+ def line_prefix(offset)
99
+ before = @text.byteslice(0...offset)
100
+ before.byteslice((before.b.rindex(/[\r\n]/n) || -1) + 1..)
101
+ end
102
+
103
+ def eol
104
+ @eol ||= @text[/\r\n|\r|\n/] || "\n"
105
+ end
106
+
107
+ def insertion_index(parent, key, after)
108
+ case parent&.kind
109
+ when :object
110
+ raise TypeError, "Object path keys must be Strings" unless key.is_a?(String)
111
+ raise ArgumentError, "Property already exists; use set" if parent.children.any? { |child| child.key == key }
112
+ return parent.children.length if after.nil?
113
+
114
+ found = parent.children.rindex { |child| child.key == after }
115
+ raise KeyError, "No property #{after.inspect} to insert after" unless found
116
+
117
+ found + 1
118
+ when :array
119
+ raise ArgumentError, "Use an array index instead of after" unless after.nil?
120
+ unless key.is_a?(Integer) && (0..parent.children.length).cover?(key)
121
+ raise IndexError, "Array insertion index is outside the array"
122
+ end
123
+ key
124
+ else
125
+ raise KeyError, "Parent path is not an object or array"
126
+ end
127
+ end
128
+
129
+ def separator_between(previous, event, newlines)
130
+ return :newline if previous.kind == :line
131
+ return newlines.positive? ? :newline : :space if event.is_a?(Comment)
132
+ return :none if [:colon, :comma].include?(event.kind)
133
+
134
+ opening = [:object, :array].include?(previous.kind)
135
+ return opening ? :none : :newline if [:object_end, :array_end].include?(event.kind)
136
+ return :newline if opening || previous.kind == :comma
137
+ return :space if previous.kind == :colon
138
+ return newlines.positive? ? :newline : :space if previous.is_a?(Comment)
139
+
140
+ :none
141
+ end
142
+
143
+ def append_entry(parent, payload)
144
+ closing = token_before(parent.range.end)
145
+ last = parent.children.last
146
+ comma = last && token_after(last.range.end)&.kind == :comma
147
+ edits = []
148
+ edits << TextEdit.new(offset: last.range.end, length: 0, text: ",") if last && !comma
149
+ payload += "," if comma
150
+ if @text.byteslice(parent.range).match?(/[\r\n]/)
151
+ base = line_prefix(parent.range.begin)[/\A[ \t]*/]
152
+ first = parent.children.first
153
+ child_indent = first && line_prefix(first.range.begin)
154
+ child_indent = base + " " unless child_indent&.match?(/\A[ \t]+\z/) && child_indent.length > base.length
155
+ prefix = line_prefix(closing.range.begin)
156
+ addition = if prefix.match?(/\A[ \t]*\z/) && child_indent.start_with?(prefix)
157
+ child_indent.delete_prefix(prefix)
158
+ else
159
+ eol + child_indent
160
+ end
161
+ payload = addition + payload + eol + base
162
+ elsif last || @comments.any? { |comment| parent.range.cover?(comment.range.begin) }
163
+ payload = " " + payload unless @text.getbyte(closing.range.begin - 1) == 32
164
+ end
165
+ edits << TextEdit.new(offset: closing.range.begin, length: 0, text: payload)
166
+ edits
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kochab
4
+ # Internal scanner/parser. All offsets are bytes, even for invalid UTF-8.
5
+ class Parser
6
+ Token = Struct.new(:kind, :range, :value)
7
+ PUNCTUATION = {123 => :object, 125 => :object_end, 91 => :array,
8
+ 93 => :array_end, 58 => :colon, 44 => :comma}.freeze
9
+ VALUES = [:object, :array, :string, :number, :boolean, :null].freeze
10
+ ENDINGS = [:object_end, :array_end, :eof].freeze
11
+ def initialize(text, strict:, trailing_commas:, allow_nan:, max_depth:)
12
+ @text = text.dup.force_encoding(Encoding::UTF_8).freeze
13
+ @scanner = StringScanner.new(@text.b)
14
+ @strict, @trailing_commas, @allow_nan = strict, trailing_commas, allow_nan
15
+ @max_depth = max_depth
16
+ @errors, @comments, @tokens, @nodes = [], [], [], []
17
+ @index = 0
18
+ end
19
+
20
+ def parse
21
+ scan
22
+ root = parse_value(0)
23
+ until current.kind == :eof
24
+ diagnose(current.range, :unexpected_token, "Unexpected token after root value")
25
+ advance
26
+ end
27
+ document = Document.new(@text, root, @errors, @comments, @tokens)
28
+ attach_comments(document)
29
+ raise ParseError, @errors if @strict && !document.valid?
30
+
31
+ document
32
+ end
33
+
34
+ private
35
+
36
+ def diagnose(range, code, message, severity = :error)
37
+ @errors << Error.new(range: range, code: code, message: message, severity: severity)
38
+ end
39
+
40
+ def scan
41
+ diagnose(0...@text.bytesize, :invalid_encoding, "Input is not valid UTF-8") unless @text.valid_encoding?
42
+ scan_bom
43
+
44
+ until @scanner.eos?
45
+ next if @scanner.skip(/[ \t\r\n]+/n)
46
+
47
+ scan_token
48
+ end
49
+ @tokens << Token.new(:eof, @text.bytesize...@text.bytesize)
50
+ end
51
+
52
+ def scan_bom
53
+ return unless @scanner.scan(/\xEF\xBB\xBF/n)
54
+
55
+ diagnose(0...3, :unexpected_bom, "BOM is not permitted in strict JSON") if @strict
56
+ end
57
+
58
+ def scan_token
59
+ start = @scanner.pos
60
+ byte = @scanner.peek(1).getbyte(0)
61
+ if (kind = PUNCTUATION[byte])
62
+ @scanner.pos += 1
63
+ @tokens << Token.new(kind, start...@scanner.pos)
64
+ elsif byte == 34
65
+ scan_string(start)
66
+ elsif @scanner.scan(%r{//[^\r\n]*|/\*(?:[^*]++|\*(?!/))*\*/}n)
67
+ comment(start, @scanner.matched.start_with?("//") ? :line : :block)
68
+ elsif @scanner.scan(%r{/\*}n)
69
+ @scanner.terminate
70
+ comment(start, :block)
71
+ diagnose(start...@scanner.pos, :unterminated_comment, "Unterminated block comment")
72
+ elsif @scanner.scan(/(?:-?Infinity|NaN)/n)
73
+ raw = @scanner.matched
74
+ value = raw == "NaN" ? Float::NAN : (raw.start_with?("-") ? -Float::INFINITY : Float::INFINITY)
75
+ @tokens << Token.new(:number, start...@scanner.pos, value)
76
+ diagnose(start...@scanner.pos, :invalid_number, "Non-finite numbers are disabled") if @strict || !@allow_nan
77
+ elsif @scanner.scan(/-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/n)
78
+ raw = @scanner.matched
79
+ value = raw.match?(/[.eE]/) ? JSON.parse(raw) : raw.to_i
80
+ @tokens << Token.new(:number, start...@scanner.pos, value)
81
+ elsif @scanner.scan(/true|false|null/n)
82
+ raw = @scanner.matched
83
+ @tokens << Token.new(raw == "null" ? :null : :boolean, start...@scanner.pos,
84
+ raw == "null" ? nil : raw == "true")
85
+ else
86
+ @scanner.scan(/[^\s{}\[\]:,"\/]+/n) || @scanner.get_byte
87
+ diagnose(start...@scanner.pos, :invalid_token, "Invalid token")
88
+ @tokens << Token.new(:invalid, start...@scanner.pos)
89
+ end
90
+ end
91
+
92
+ def scan_string(start)
93
+ valid = @scanner.scan(/"(?:[^"\\\x00-\x1f]++|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"/n)
94
+ closed = valid || @scanner.scan(/"(?:[^"\\\r\n]++|\\[^\r\n])*"/n)
95
+ @scanner.scan(/"(?:[^"\\\r\n]++|\\[^\r\n])*\\?/n) unless closed
96
+ raw = @scanner.matched.force_encoding(Encoding::UTF_8)
97
+ range = start...@scanner.pos
98
+ unless closed
99
+ diagnose(range, :unterminated_string, "Unterminated string")
100
+ # An unfinished escape has no decoded character yet.
101
+ raw = (raw.b.sub(/\\\z/n, "") + '"').force_encoding(Encoding::UTF_8)
102
+ end
103
+ @tokens << Token.new(:string, range, decode_string(raw, valid, range))
104
+ end
105
+
106
+ def decode_string(raw, valid, range)
107
+ begin
108
+ if raw.b.match?(/[\\\x00-\x1f]/n)
109
+ # Older json releases silently accept unknown escapes, so validate
110
+ # their syntax ourselves before asking the decoder for their value.
111
+ raise JSON::ParserError, "Invalid string escape or control character" unless valid
112
+ value = JSON.parse(raw)
113
+ else
114
+ value = raw.byteslice(1, raw.bytesize - 2)
115
+ end
116
+ raise JSON::ParserError, "Invalid Unicode string" unless value.valid_encoding?
117
+ rescue JSON::ParserError, EncodingError
118
+ diagnose(range, :invalid_string, "Invalid string escape, control character, or Unicode")
119
+ value = raw.byteslice(1, [raw.bytesize - 2, 0].max).scrub
120
+ end
121
+ value
122
+ end
123
+
124
+ def comment(start, kind)
125
+ range = start...@scanner.pos
126
+ @comments << Comment.new(range: range, text: @text.byteslice(range), kind: kind)
127
+ diagnose(range, :comment_not_allowed, "Comments are not permitted in strict JSON") if @strict
128
+ end
129
+
130
+ def current
131
+ @tokens[@index]
132
+ end
133
+
134
+ def advance
135
+ token = current
136
+ @index += 1 unless token.kind == :eof
137
+ token
138
+ end
139
+
140
+ def node(kind, range, value = nil, children = [], key_range = nil, key = nil)
141
+ result = Node.new(kind: kind, range: range, value: value, children: children,
142
+ key_range: key_range, key: key, leading_comments: [])
143
+ children.each { |child| child.parent = result }
144
+ @nodes << result
145
+ result
146
+ end
147
+
148
+ def parse_value(depth)
149
+ token = current
150
+ case token.kind
151
+ when :object, :array
152
+ if depth >= @max_depth
153
+ diagnose(token.range, :depth_limit, "Maximum nesting depth exceeded")
154
+ skip_container
155
+ return node(:null, token.range.begin...@tokens[@index - 1].range.end)
156
+ end
157
+ parse_container(depth + 1)
158
+ when :string, :number, :boolean, :null
159
+ advance
160
+ node(token.kind, token.range, token.value)
161
+ else
162
+ diagnose(token.range, :expected_value, "Expected a value")
163
+ advance unless ENDINGS.include?(token.kind) || token.kind == :comma
164
+ node(:null, token.range.begin...token.range.begin)
165
+ end
166
+ end
167
+
168
+ def skip_container
169
+ depth = 0
170
+ loop do
171
+ kind = advance.kind
172
+ depth += 1 if [:object, :array].include?(kind)
173
+ depth -= 1 if [:object_end, :array_end].include?(kind)
174
+ break if depth.zero? || current.kind == :eof
175
+ end
176
+ end
177
+
178
+ def parse_container(depth)
179
+ opening = advance
180
+ object = opening.kind == :object
181
+ ending = object ? :object_end : :array_end
182
+ children, value = [], object ? {} : []
183
+ comma = nil
184
+ until current.kind == ending || current.kind == :eof
185
+ if [:object_end, :array_end].include?(current.kind)
186
+ diagnose(current.range, :mismatched_bracket, "Mismatched closing bracket")
187
+ advance
188
+ next
189
+ end
190
+ if current.kind == :comma
191
+ diagnose(current.range, :unexpected_comma, "Unexpected comma")
192
+ comma = advance
193
+ next
194
+ end
195
+ if children.any? && !comma
196
+ diagnose(current.range, :expected_comma, "Expected a comma")
197
+ end
198
+ before = @index
199
+ if object
200
+ child = parse_property(depth)
201
+ if child
202
+ diagnose(child.key_range, :duplicate_key, "Duplicate key #{child.key.inspect}", :warning) if value.key?(child.key)
203
+ value[child.key] = child.value
204
+ children << child
205
+ end
206
+ else
207
+ child = parse_value(depth)
208
+ child.key = children.length
209
+ children << child
210
+ value << child.value
211
+ end
212
+ advance if before == @index
213
+ comma = current.kind == :comma ? advance : nil
214
+ end
215
+ finish = finish_container(ending, comma)
216
+ node(opening.kind, opening.range.begin...finish, value, children)
217
+ end
218
+
219
+ def finish_container(ending, comma)
220
+ if current.kind == ending
221
+ diagnose(comma.range, :trailing_comma, "Trailing comma is not permitted") if comma && (@strict || !@trailing_commas)
222
+ advance.range.end
223
+ else
224
+ diagnose(current.range, :unclosed_container, "Expected #{ending == :object_end ? '}' : ']'}")
225
+ @text.bytesize
226
+ end
227
+ end
228
+
229
+ def parse_property(depth)
230
+ unless current.kind == :string
231
+ diagnose(current.range, :expected_key, "Expected a quoted property name")
232
+ advance
233
+ return nil
234
+ end
235
+ key = advance
236
+ if current.kind == :colon
237
+ advance
238
+ else
239
+ diagnose(current.range, :expected_colon, "Expected ':' after property name")
240
+ end
241
+ value = parse_value(depth)
242
+ node(:property, key.range.begin...value.range.end, value.value, [value], key.range, key.value)
243
+ end
244
+
245
+ def attach_comments(document)
246
+ return if @comments.empty?
247
+
248
+ ends = @nodes
249
+ starts = comment_targets(document.root)
250
+ next_comment = nil
251
+ leading_target = nil
252
+ @comments.reverse_each do |comment|
253
+ previous = comment_target_before(ends, comment)
254
+ following = starts.bsearch { |entry| entry.range.begin >= comment.range.end }
255
+ gap_before = previous && @text.byteslice(previous.range.end...comment.range.begin).b
256
+ if next_comment && leading_target && (!following || next_comment.range.begin < following.range.begin)
257
+ following = leading_target
258
+ end_of_gap = next_comment.range.begin
259
+ else
260
+ end_of_gap = following&.range&.begin
261
+ end
262
+ gap_after = end_of_gap && @text.byteslice(comment.range.end...end_of_gap).b
263
+ leading_target = nil
264
+ if previous && !previous.trailing_comment && gap_before.match?(/\A[ \t,]*\z/n)
265
+ previous.trailing_comment = comment
266
+ elsif following && gap_after.match?(/\A\s*\z/) && gap_after.scan(/\r\n|\r|\n/).length <= 1
267
+ following.leading_comments.unshift(comment)
268
+ leading_target = following
269
+ else
270
+ document.floating_comments << comment
271
+ end
272
+ next_comment = comment
273
+ end
274
+ document.floating_comments.sort_by! { |comment| comment.range.begin }
275
+ end
276
+
277
+ def comment_targets(root)
278
+ starts = []
279
+ pending = [root]
280
+ until pending.empty?
281
+ entry = pending.pop
282
+ starts << entry unless entry.parent&.kind == :property
283
+ entry.children.reverse_each { |child| pending << child }
284
+ end
285
+ starts
286
+ end
287
+
288
+ def comment_target_before(ends, comment)
289
+ # Nodes are constructed in postorder, so end offsets are already sorted.
290
+ before = ends.bsearch_index { |entry| entry.range.end > comment.range.begin } || ends.length
291
+ previous = before.positive? ? ends[before - 1] : nil
292
+ # Equal end offsets belong to the innermost value, before its property.
293
+ while before > 1 && ends[before - 2].range.end == previous.range.end
294
+ before -= 1
295
+ previous = ends[before - 1]
296
+ end
297
+ previous
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kochab
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kochab.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "strscan"
5
+ require_relative "kochab/version"
6
+
7
+ # JSON with comments, source ranges, and non-destructive editing.
8
+ module Kochab
9
+ Error = Struct.new(:range, :code, :message, :severity, keyword_init: true)
10
+ Comment = Struct.new(:range, :text, :kind, keyword_init: true)
11
+ TextEdit = Struct.new(:offset, :length, :text, keyword_init: true)
12
+ Node = Struct.new(:kind, :range, :key_range, :value, :children,
13
+ :leading_comments, :trailing_comment, :key, :parent, keyword_init: true)
14
+
15
+ class ParseError < StandardError
16
+ attr_reader :errors
17
+
18
+ def initialize(errors)
19
+ @errors = errors
20
+ error = errors.find { |entry| entry.severity == :error }
21
+ super("#{error.message} at byte #{error.range.begin}")
22
+ end
23
+ end
24
+
25
+ # Parse UTF-8 bytes. Syntax errors are collected unless strict is true.
26
+ # Duplicate keys are warnings, with the final occurrence winning.
27
+ def self.parse(text, strict: false, trailing_commas: true, allow_nan: false, max_depth: 512)
28
+ raise TypeError, "text must be a String" unless text.is_a?(String)
29
+ unless max_depth.is_a?(Integer) && (1..512).cover?(max_depth)
30
+ raise ArgumentError, "max_depth must be an Integer between 1 and 512"
31
+ end
32
+
33
+ Parser.new(text, strict: strict, trailing_commas: trailing_commas,
34
+ allow_nan: allow_nan, max_depth: max_depth).parse
35
+ end
36
+
37
+ # Apply non-overlapping byte edits against one source snapshot.
38
+ # Insertions at the same byte offset are concatenated in input order.
39
+ def self.apply(text, edits)
40
+ raise TypeError, "text must be a String" unless text.is_a?(String)
41
+
42
+ validate_edits(text, edits)
43
+ ordered = edits.each_with_index.sort_by { |edit, index| [edit.offset, edit.length.zero? ? 0 : 1, index] }
44
+ cursor = 0
45
+ output = String.new(encoding: Encoding::BINARY)
46
+ ordered.each do |edit, _|
47
+ raise ArgumentError, "Text edits overlap" if edit.offset < cursor
48
+
49
+ output << text.byteslice(cursor...edit.offset).b << edit.text.b
50
+ cursor = edit.offset + edit.length
51
+ end
52
+ output << text.byteslice(cursor..).b
53
+ output.force_encoding(text.encoding)
54
+ end
55
+
56
+ def self.validate_edits(text, edits)
57
+ edits.each do |edit|
58
+ unless edit.is_a?(TextEdit) && edit.offset.is_a?(Integer) && edit.length.is_a?(Integer) &&
59
+ edit.text.is_a?(String) && edit.offset >= 0 && edit.length >= 0 && edit.offset + edit.length <= text.bytesize
60
+ raise ArgumentError, "Invalid text edit"
61
+ end
62
+ if text.encoding == Encoding::UTF_8 && text.valid_encoding?
63
+ [edit.offset, edit.offset + edit.length].each do |offset|
64
+ byte = text.getbyte(offset)
65
+ raise ArgumentError, "Edit splits a UTF-8 character" if byte && byte & 0xc0 == 0x80
66
+ end
67
+ raise ArgumentError, "Replacement is not valid UTF-8" unless edit.text.b.force_encoding(Encoding::UTF_8).valid_encoding?
68
+ end
69
+ end
70
+ end
71
+ private_class_method :validate_edits
72
+
73
+ # Normalize whitespace while retaining every comment and token verbatim.
74
+ def self.format(text, indent: 2, keep_blank_lines: 1)
75
+ parse(text).format(indent: indent, keep_blank_lines: keep_blank_lines)
76
+ end
77
+ end
78
+
79
+ require_relative "kochab/parser"
80
+ require_relative "kochab/document"
81
+ require_relative "kochab/editing"
data/sig/kochab.rbs ADDED
@@ -0,0 +1,69 @@
1
+ module Kochab
2
+ VERSION: String
3
+ type value = nil | bool | Integer | Float | String | Array[value] | Hash[String, value]
4
+ type path = Array[String | Integer]
5
+
6
+ def self.parse: (String text, ?strict: bool, ?trailing_commas: bool, ?allow_nan: bool, ?max_depth: Integer) -> Document
7
+ def self.apply: (String text, Array[TextEdit] edits) -> String
8
+ def self.format: (String text, ?indent: Integer, ?keep_blank_lines: Integer) -> String
9
+
10
+ class Error < Struct[untyped]
11
+ attr_accessor range: Range[Integer]
12
+ attr_accessor code: Symbol
13
+ attr_accessor message: String
14
+ attr_accessor severity: Symbol
15
+ def initialize: (?range: Range[Integer], ?code: Symbol, ?message: String, ?severity: Symbol) -> void
16
+ end
17
+
18
+ class Comment < Struct[untyped]
19
+ attr_accessor range: Range[Integer]
20
+ attr_accessor text: String
21
+ attr_accessor kind: Symbol
22
+ def initialize: (?range: Range[Integer], ?text: String, ?kind: Symbol) -> void
23
+ end
24
+
25
+ class TextEdit < Struct[untyped]
26
+ attr_accessor offset: Integer
27
+ attr_accessor length: Integer
28
+ attr_accessor text: String
29
+ def initialize: (?offset: Integer, ?length: Integer, ?text: String) -> void
30
+ end
31
+
32
+ class Node < Struct[untyped]
33
+ attr_accessor kind: Symbol
34
+ attr_accessor range: Range[Integer]
35
+ attr_accessor key_range: Range[Integer]?
36
+ attr_accessor value: value
37
+ attr_accessor children: Array[Node]
38
+ attr_accessor leading_comments: Array[Comment]
39
+ attr_accessor trailing_comment: Comment?
40
+ attr_accessor key: (String | Integer)?
41
+ attr_accessor parent: Node?
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
+ end
44
+
45
+ class ParseError < StandardError
46
+ attr_reader errors: Array[Error]
47
+ def initialize: (Array[Error] errors) -> void
48
+ end
49
+
50
+ class Document
51
+ attr_reader text: String
52
+ attr_reader root: Node
53
+ attr_reader errors: Array[Error]
54
+ attr_reader comments: Array[Comment]
55
+ attr_reader floating_comments: Array[Comment]
56
+ def value: () -> value
57
+ def valid?: () -> bool
58
+ def node_at: (Integer offset) -> Node?
59
+ def path_at: (Integer offset) -> path?
60
+ def range_of: (path path) -> Range[Integer]?
61
+ def key_range_of: (path path) -> Range[Integer]?
62
+ def utf16_position_at: (Integer offset) -> [Integer, Integer]
63
+ def offset_at_utf16_position: (Integer line, Integer character) -> Integer
64
+ def set: (path path, value value) -> Array[TextEdit]
65
+ def remove: (path path) -> Array[TextEdit]
66
+ def insert: (path path, value value, ?after: String?) -> Array[TextEdit]
67
+ def format: (?indent: Integer, ?keep_blank_lines: Integer) -> String
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kochab
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ 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
+ email:
15
+ - t.yudai92@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - examples/settings.rb
24
+ - lib/kochab.rb
25
+ - lib/kochab/document.rb
26
+ - lib/kochab/editing.rb
27
+ - lib/kochab/parser.rb
28
+ - lib/kochab/version.rb
29
+ - sig/kochab.rbs
30
+ homepage: https://github.com/noxdea/kochab
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ source_code_uri: https://github.com/noxdea/kochab
35
+ changelog_uri: https://github.com/noxdea/kochab/blob/main/CHANGELOG.md
36
+ rubygems_mfa_required: 'true'
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.1'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 4.0.19
52
+ specification_version: 4
53
+ summary: Recoverable JSONC parsing and edits that preserve comments and formatting
54
+ test_files: []