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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +205 -0
- data/examples/settings.rb +19 -0
- data/lib/kochab/document.rb +122 -0
- data/lib/kochab/editing.rb +169 -0
- data/lib/kochab/parser.rb +300 -0
- data/lib/kochab/version.rb +5 -0
- data/lib/kochab.rb +81 -0
- data/sig/kochab.rbs +69 -0
- metadata +54 -0
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
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
|
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: []
|