denebola 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 +120 -0
- data/lib/denebola/anchor.rb +33 -0
- data/lib/denebola/cursor.rb +42 -0
- data/lib/denebola/dimensions.rb +10 -0
- data/lib/denebola/error.rb +5 -0
- data/lib/denebola/point.rb +19 -0
- data/lib/denebola/rope.rb +309 -0
- data/lib/denebola/text_summary.rb +100 -0
- data/lib/denebola/tree.rb +271 -0
- data/lib/denebola/version.rb +5 -0
- data/lib/denebola.rb +11 -0
- data/sig/denebola.rbs +155 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ddb3a396701e9479f38cc53d23f86e568e7d2e31b4a38576d6670dc8f5026c56
|
|
4
|
+
data.tar.gz: 718554282d9670f3c656e06b67f9219679be4ec626cad2c390f49d8ff720cd27
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c4af8ed526cacc5ed81c91614349b79d985ee8851d7c5ee47e58cc7bea011aa6ce1f461c390a1ec5fdc5d731ae16a9ce5ad24e4291d8fe34c4e3926341e8f3a9
|
|
7
|
+
data.tar.gz: 4728022bcf1941df19c6cbb808ad8ece063582402d41d44f7a24be21924701e8b081edaef15999538c02c95de990327a297511b11a7aa184b81b0bfda3e3c609
|
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,120 @@
|
|
|
1
|
+
# Denebola
|
|
2
|
+
|
|
3
|
+
Persistent summary B+ trees and Unicode text ropes, in pure Ruby. Ruby 3.1 or later; no runtime dependencies.
|
|
4
|
+
|
|
5
|
+
Nodes, text chunks, and summaries are frozen. An edit shares untouched subtrees with the previous value. Keeping a snapshot is an ordinary assignment, so readers can keep analyzing old text while an editor creates a new version.
|
|
6
|
+
|
|
7
|
+
This source is version 0.1.0, not yet published. Build it with `gem build denebola.gemspec`, or use `gem "denebola", path: "/path/to/denebola"` in a Gemfile.
|
|
8
|
+
|
|
9
|
+
## Text editing
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "denebola"
|
|
13
|
+
|
|
14
|
+
rope = Denebola::Rope.new("hello\nworld")
|
|
15
|
+
snapshot = rope
|
|
16
|
+
rope = rope.insert(5, ", there")
|
|
17
|
+
rope.line(0) # => "hello, there"
|
|
18
|
+
snapshot.to_s # => "hello\nworld"
|
|
19
|
+
rope.bytesize # => 18
|
|
20
|
+
rope.length # => 18 Unicode codepoints
|
|
21
|
+
rope.line_count # => 2
|
|
22
|
+
rope.byteslice(0, 5).to_s # => "hello"
|
|
23
|
+
rope = rope.delete(0...5)
|
|
24
|
+
rope = rope.replace(0...7, "goodbye")
|
|
25
|
+
rope.each_chunk { |text| puts text } # Frozen strings, without flattening
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
All `replace`, `apply_edits`, and `byteslice` ranges address **bytes in UTF-8**, and are checked for bounds and codepoint boundaries. Inclusive and exclusive Ruby ranges work; beginless/endless ranges are accepted. Invalid encodings, split codepoints, and overlapping batch edits raise exceptions. Input strings are copied or shared safely with Ruby's copy-on-write strings; later mutation of the source cannot alter a rope.
|
|
29
|
+
|
|
30
|
+
`apply_edits([[range, text], ...])` applies nonoverlapping edits against one original snapshot, sorting them into source order. Adjacent ranges are allowed. Chunks preserve extended grapheme clusters when building or joining text; a single unusually long grapheme may exceed the configured chunk size. Explicit byte slices and edits may operate between codepoints inside a grapheme.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
rope = Denebola::Rope.new("hello\nworld")
|
|
34
|
+
rope.apply_edits([[0...5, "Hi"], [6...11, "Ruby"]]).to_s # => "Hi\nRuby"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Lines and positions
|
|
38
|
+
|
|
39
|
+
LF, CRLF, CR, U+2028, and U+2029 are line breaks. `line(row)` omits its terminator. Rows are zero based; empty text and a final empty row after a terminator each count as a line.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
rope = Denebola::Rope.new("ð\næ¥æ¬")
|
|
43
|
+
rope.point_at(8) # => Point(row: 1, column: 1)
|
|
44
|
+
rope.offset_at(Denebola::Point.new(1, 1)) # => 8
|
|
45
|
+
rope.line_start(1) # => 5
|
|
46
|
+
rope.utf16_offset_at(8) # => 4
|
|
47
|
+
rope.offset_at_utf16(4) # => 8
|
|
48
|
+
rope.utf16_point_at(4) # => Point(row: 0, column: 2)
|
|
49
|
+
rope.offset_at_utf16_point(Denebola::Point.new(0, 2)) # => 4
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Point` accepts positional or keyword `row` and `column`. Unqualified offsets are UTF-8 byte offsets. Normal columns count Unicode codepoints, not screen cells or graphemes. The UTF-16 methods count code units and reject offsets inside a surrogate pair. `offset_at` and `offset_at_utf16_point` require a column within the line's content. A byte position between CR and LF normalizes to the following row, column zero; converting that point back returns the position after LF.
|
|
53
|
+
|
|
54
|
+
`rope.summary` exposes `bytesize`, `length`, `utf16_length`, `break_count`, `longest_row` (earliest row with maximum width), `longest_row_length`, `first_line_length`, and `last_line_length`. `TextSummary.zero` is the identity and `summary + other_summary` combines concatenated text, including CRLF across a boundary.
|
|
55
|
+
|
|
56
|
+
## Anchors
|
|
57
|
+
|
|
58
|
+
Anchors transform explicitly using the same finite byte ranges as an edit, without retaining the entire edit history:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
rope = Denebola::Rope.new("hello")
|
|
62
|
+
anchor = rope.anchor(2, bias: :right)
|
|
63
|
+
edits = [[2...2, "XYZ"]]
|
|
64
|
+
anchor = anchor.transform(edits)
|
|
65
|
+
rope = rope.apply_edits(edits)
|
|
66
|
+
anchor.offset # => 5
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`:left` keeps an anchor before text inserted at its position; `:right` keeps it after. Anchors covered by a replacement collapse to the corresponding side of the replacement. Offsets after an edit move by its byte-length delta. Anchor transformation returns a new anchor; it does not mutate the original or automatically observe a rope.
|
|
70
|
+
|
|
71
|
+
## Generic summary tree
|
|
72
|
+
|
|
73
|
+
Items expose `summary`. The summary class supplies `.zero` and `#+`, with an associative addition and identity. Items and their summaries must be immutable. A dimension is a summary attribute name, a callable, or an object with `from_summary(summary)`; its projection must be monotone along the sequence.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Weight = Struct.new(:weight) do
|
|
77
|
+
def self.zero = new(0).freeze
|
|
78
|
+
def +(other) = self.class.new(weight + other.weight).freeze
|
|
79
|
+
def summary = self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
tree = Denebola::Tree.new([Weight.new(2).freeze, Weight.new(3).freeze], summary: Weight)
|
|
83
|
+
tree = tree.push(Weight.new(5).freeze)
|
|
84
|
+
cursor = tree.cursor(:weight).seek(2)
|
|
85
|
+
cursor.item.weight # => 3
|
|
86
|
+
cursor.summary.weight # => 2: summary before the current item
|
|
87
|
+
cursor.next.weight # => 5
|
|
88
|
+
cursor.prev.weight # => 3
|
|
89
|
+
cursor.seek(2, bias: :left).item.weight # => 2
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`Tree.from(items, summary: ...)` and `Tree.new(items, summary: ...)` bulk-build an already ordered sequence. `append` joins another compatible tree or enumerable. `tree[index]`, `slice(index, count)`, and `split_at(index)` use item indexes; `replace_at(index, items)` replaces one item, or appends when `index == size`. Every update returns a new tree.
|
|
93
|
+
|
|
94
|
+
`cursor.seek(value)` chooses the item whose ending dimension exceeds the target; `bias: :left` also includes equality. At the end, `item` is `nil`, `index == tree.size`, and `summary` is the whole summary. `cursor.read_until(value)` returns complete items up to the target boundary and advances the cursor; it does not split an item. `next` and `prev` return the newly selected item or `nil`.
|
|
95
|
+
|
|
96
|
+
Text dimensions are also available as `Denebola::Dimensions::BYTES`, `CHARACTERS`, `UTF16`, and `LINE_BREAKS`. B+ nodes hold cumulative summaries and item counts; navigation uses binary search. Editing copies touched chunks and ancestor paths, and splitting/joining preserves occupancy and equal leaf depth. Returning a string or a long line necessarily costs at least its output size. `check_invariants!` checks occupancy, frozen nodes, depth, prefix counts, and summaries and is intended for tests.
|
|
97
|
+
|
|
98
|
+
## Benchmarks and validation
|
|
99
|
+
|
|
100
|
+
Run `bundle install`, then `bundle exec rake test`. The default suite includes 100,000 deterministic random Unicode edits compared with Ruby `String`, 5,000 summary monoid cases, immutable snapshots, split/join occupancy checks, all supported newline types, surrogate boundaries, and retained node counts after 10,000 edits. `bundle exec rake test:oracle` runs the property tests alone. `ruby tools/check_isolation.rb` checks runtime independence; `sig/denebola.rbs` describes the public API.
|
|
101
|
+
|
|
102
|
+
`bundle exec rake bench` compares fanouts 8/16/32/64 and chunk sizes 256/512/1024/2048 before exercising a 1,000,000-line ASCII document (11,000,000 bytes). Timings are five-batch medians after warmup. Defaults are fanout **16**, chunk size **1024 bytes**: smaller chunks improve some edits but allocate more nodes; these defaults meet the edit and retained-memory budgets together. Override them with `Rope.new(text, branching: 8, chunk_size: 512)`.
|
|
103
|
+
|
|
104
|
+
Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
|
|
105
|
+
|
|
106
|
+
| Operation | Measured | Design budget |
|
|
107
|
+
|---|---:|---:|
|
|
108
|
+
| Build 11 MB | 39.5 ms | 800 ms for 10 MB |
|
|
109
|
+
| Insert one ASCII character | 12.3 µs | 50 µs |
|
|
110
|
+
| Read a line | 1.38 µs | 5 µs |
|
|
111
|
+
| Byte offset â Point | 1.92 µs | 10 µs |
|
|
112
|
+
| Point â byte offset | 1.17 µs | 10 µs |
|
|
113
|
+
| Slice 1 KB | 6.79 µs | 20 µs |
|
|
114
|
+
| Retained Ruby object memory | 33.84 MiB | 40 MiB |
|
|
115
|
+
|
|
116
|
+
Performance depends on document content, Ruby version, and hardware. Memory is measured with `ObjectSpace` after releasing the input and collecting garbage; it is not process RSS. `rake bench:assert` uses explicitly wider timing ceilings for shared CI hardware, while retaining the 40 MiB memory ceiling. CI tests Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 on Linux, macOS, and Windows.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
[MIT](LICENSE.txt).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
# A position transformed explicitly by edits against its current snapshot.
|
|
5
|
+
class Anchor
|
|
6
|
+
attr_reader :offset, :bias
|
|
7
|
+
def initialize(offset, bias: :right)
|
|
8
|
+
raise ArgumentError, "offset must be nonnegative" unless offset.is_a?(Integer) && offset >= 0
|
|
9
|
+
raise ArgumentError, "bias must be :left or :right" unless %i[left right].include?(bias)
|
|
10
|
+
@offset, @bias = offset, bias
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def transform(edits)
|
|
15
|
+
delta = 0
|
|
16
|
+
previous = 0
|
|
17
|
+
transformed = nil
|
|
18
|
+
edits.sort_by { |range, _| range.begin }.each do |range, text|
|
|
19
|
+
start = range.begin
|
|
20
|
+
finish = range.end + (range.exclude_end? ? 0 : 1)
|
|
21
|
+
raise ArgumentError, "invalid or overlapping edits" unless start.is_a?(Integer) && start >= previous && finish >= start
|
|
22
|
+
previous = finish
|
|
23
|
+
if offset < start
|
|
24
|
+
transformed ||= offset + delta
|
|
25
|
+
elsif offset <= finish
|
|
26
|
+
transformed ||= start + delta + (bias == :right ? text.bytesize : 0)
|
|
27
|
+
end
|
|
28
|
+
delta += text.bytesize - (finish - start)
|
|
29
|
+
end
|
|
30
|
+
self.class.new(transformed || offset + delta, bias: bias)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
class Cursor
|
|
5
|
+
attr_reader :tree, :dimension, :index, :item, :summary
|
|
6
|
+
|
|
7
|
+
def initialize(tree, dimension)
|
|
8
|
+
@tree, @dimension = tree, dimension
|
|
9
|
+
@index = 0
|
|
10
|
+
@item = tree[0]
|
|
11
|
+
@summary = tree.prefix_summary(0)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def seek(value, bias: :right)
|
|
15
|
+
@index, @item, @summary = tree.locate(value, dimension, bias: bias)
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def next
|
|
20
|
+
return nil if index >= tree.size
|
|
21
|
+
@summary += @item.summary
|
|
22
|
+
@index += 1
|
|
23
|
+
@item = tree[index]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prev
|
|
27
|
+
return nil if index.zero?
|
|
28
|
+
@index -= 1
|
|
29
|
+
@summary = tree.prefix_summary(index)
|
|
30
|
+
@item = tree[index]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns complete items from the cursor up to the target dimension boundary.
|
|
34
|
+
def read_until(value, bias: :right)
|
|
35
|
+
finish, = tree.locate(value, dimension, bias: bias)
|
|
36
|
+
raise RangeError, "target is before cursor" if finish < index
|
|
37
|
+
result = tree.slice(index, finish - index)
|
|
38
|
+
seek(value, bias: bias)
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
module Dimensions
|
|
5
|
+
BYTES = ->(summary) { summary.bytesize }
|
|
6
|
+
CHARACTERS = ->(summary) { summary.length }
|
|
7
|
+
UTF16 = ->(summary) { summary.utf16_length }
|
|
8
|
+
LINE_BREAKS = ->(summary) { summary.break_count }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
class Point
|
|
5
|
+
attr_reader :row, :column
|
|
6
|
+
def initialize(row = 0, column = 0, **keywords)
|
|
7
|
+
@row, @column = keywords.fetch(:row, row), keywords.fetch(:column, column)
|
|
8
|
+
unless @row.is_a?(Integer) && @column.is_a?(Integer) && @row >= 0 && @column >= 0
|
|
9
|
+
raise ArgumentError, "row and column must be nonnegative integers"
|
|
10
|
+
end
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
def ==(other) = other.is_a?(Point) && row == other.row && column == other.column
|
|
14
|
+
alias eql? ==
|
|
15
|
+
def hash = [row, column].hash
|
|
16
|
+
def to_a = [row, column]
|
|
17
|
+
def inspect = "#<Denebola::Point row=#{row} column=#{column}>"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
class Rope
|
|
5
|
+
DEFAULT_CHUNK_SIZE = 1024
|
|
6
|
+
LINE_BREAK = /\r\n|[\r\n\u2028\u2029]/
|
|
7
|
+
|
|
8
|
+
class Chunk
|
|
9
|
+
attr_reader :text, :summary, :line_ends
|
|
10
|
+
def initialize(text)
|
|
11
|
+
@text = text.frozen? ? text : text.dup.freeze
|
|
12
|
+
@line_ends = []
|
|
13
|
+
@summary = TextSummary.from_text(text, line_ends: @line_ends)
|
|
14
|
+
@line_ends.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :tree, :chunk_size
|
|
20
|
+
protected :tree
|
|
21
|
+
|
|
22
|
+
def initialize(text = "", chunk_size: DEFAULT_CHUNK_SIZE, branching: Tree::DEFAULT_BRANCHING, tree: nil)
|
|
23
|
+
raise ArgumentError, "chunk_size must be an integer >= 4" unless chunk_size.is_a?(Integer) && chunk_size >= 4
|
|
24
|
+
@chunk_size = chunk_size
|
|
25
|
+
@tree = tree || Tree.new(chunks(normalize_text(text)), summary: TextSummary, branching: branching)
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def summary = tree.summary
|
|
30
|
+
def bytesize = summary.bytesize
|
|
31
|
+
def length = summary.length
|
|
32
|
+
alias size length
|
|
33
|
+
def utf16_length = summary.utf16_length
|
|
34
|
+
def line_count = summary.break_count + 1
|
|
35
|
+
def empty? = bytesize.zero?
|
|
36
|
+
|
|
37
|
+
def each_chunk
|
|
38
|
+
return enum_for(__method__) unless block_given?
|
|
39
|
+
tree.each { |chunk| yield chunk.text }
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_s
|
|
44
|
+
text = String.new(capacity: bytesize, encoding: Encoding::UTF_8)
|
|
45
|
+
each_chunk { |chunk| text << chunk }
|
|
46
|
+
text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def insert(offset, text) = replace(offset...offset, text)
|
|
50
|
+
def delete(range) = replace(range, "")
|
|
51
|
+
|
|
52
|
+
def replace(range, text)
|
|
53
|
+
start, finish = byte_bounds(range)
|
|
54
|
+
replacement = normalize_text(text)
|
|
55
|
+
first_index, first, prefix = locate_byte_offset(start)
|
|
56
|
+
last_index, last, suffix = locate_byte_offset(finish)
|
|
57
|
+
if first_index.positive? && start == prefix.bytesize
|
|
58
|
+
first_index -= 1
|
|
59
|
+
first = tree[first_index]
|
|
60
|
+
prefix = tree.prefix_summary(first_index)
|
|
61
|
+
end
|
|
62
|
+
local_start = start - prefix.bytesize
|
|
63
|
+
local_finish = finish - suffix.bytesize
|
|
64
|
+
first ||= Chunk.new("")
|
|
65
|
+
last ||= Chunk.new("")
|
|
66
|
+
combined = first.text.byteslice(0, local_start) + replacement + last.text.byteslice(local_finish, last.text.bytesize - local_finish)
|
|
67
|
+
values = chunks(combined)
|
|
68
|
+
if first_index == last_index
|
|
69
|
+
updated = tree.replace_at(first_index, values)
|
|
70
|
+
else
|
|
71
|
+
tail = last_index < tree.size ? last_index + 1 : tree.size
|
|
72
|
+
updated = tree.slice(0, first_index).append(values).append(tree.slice(tail, tree.size - tail))
|
|
73
|
+
end
|
|
74
|
+
with_tree(updated)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Ranges refer to the original snapshot. Adjacent edits are allowed; overlaps are rejected.
|
|
78
|
+
def apply_edits(edits)
|
|
79
|
+
normalized = edits.map { |range, text| [*byte_bounds(range), normalize_text(text)] }.sort_by { |start, finish, _| [start, finish] }
|
|
80
|
+
previous = 0
|
|
81
|
+
normalized.each do |start, finish, _|
|
|
82
|
+
raise ArgumentError, "overlapping edits" if start < previous
|
|
83
|
+
previous = finish
|
|
84
|
+
end
|
|
85
|
+
return self if normalized.empty?
|
|
86
|
+
return replace(normalized[0][0]...normalized[0][1], normalized[0][2]) if normalized.length == 1
|
|
87
|
+
# Split the remaining tree in order, so already consumed subtrees are never revisited.
|
|
88
|
+
result = Tree.new(summary: TextSummary, branching: tree.branching)
|
|
89
|
+
remaining = self
|
|
90
|
+
consumed = 0
|
|
91
|
+
normalized.each do |start, finish, text|
|
|
92
|
+
left, rest = remaining.split_at(start - consumed)
|
|
93
|
+
_, remaining = rest.split_at(finish - start)
|
|
94
|
+
result = join_trees(result, left.tree)
|
|
95
|
+
result = join_trees(result, Tree.new(chunks(text), summary: TextSummary, branching: tree.branching))
|
|
96
|
+
consumed = finish
|
|
97
|
+
end
|
|
98
|
+
with_tree(join_trees(result, remaining.tree))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def byteslice(offset, length = nil)
|
|
102
|
+
start, finish = offset.is_a?(Range) ? byte_bounds(offset) : byte_bounds(offset...(offset + (length || bytesize - offset)))
|
|
103
|
+
return self if start.zero? && finish == bytesize
|
|
104
|
+
first_index, first, prefix = locate_byte_offset(start)
|
|
105
|
+
last_index, last, suffix = locate_byte_offset(finish)
|
|
106
|
+
return with_tree(Tree.new(summary: TextSummary, branching: tree.branching)) if finish == start
|
|
107
|
+
local_start = start - prefix.bytesize
|
|
108
|
+
if first_index == last_index
|
|
109
|
+
return with_tree(Tree.new([Chunk.new(first.text.byteslice(local_start, finish - start))], summary: TextSummary, branching: tree.branching))
|
|
110
|
+
end
|
|
111
|
+
middle_start = first_index + (local_start.positive? ? 1 : 0)
|
|
112
|
+
result = tree.slice(middle_start, last_index - middle_start)
|
|
113
|
+
if local_start.positive?
|
|
114
|
+
head = Chunk.new(first.text.byteslice(local_start, first.text.bytesize - local_start))
|
|
115
|
+
result = Tree.new([head], summary: TextSummary, branching: tree.branching).append(result)
|
|
116
|
+
end
|
|
117
|
+
local_finish = finish - suffix.bytesize
|
|
118
|
+
result = result.push(Chunk.new(last.text.byteslice(0, local_finish))) if local_finish.positive?
|
|
119
|
+
with_tree(result)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def split_at(offset)
|
|
123
|
+
index, chunk, prefix = locate_byte_offset(offset)
|
|
124
|
+
return [self, with_tree(Tree.new(summary: TextSummary, branching: tree.branching))] unless chunk
|
|
125
|
+
left, right = tree.split_at(index)
|
|
126
|
+
local = offset - prefix.bytesize
|
|
127
|
+
if local.positive?
|
|
128
|
+
left = left.push(Chunk.new(chunk.text.byteslice(0, local)))
|
|
129
|
+
right = right.replace_at(0, [Chunk.new(chunk.text.byteslice(local, chunk.text.bytesize - local))])
|
|
130
|
+
end
|
|
131
|
+
[with_tree(left), with_tree(right)]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def line(row)
|
|
135
|
+
_, chunk, prefix, local = line_location(row)
|
|
136
|
+
return "" unless chunk
|
|
137
|
+
next_end = chunk.line_ends.bsearch { |ending| ending > local }
|
|
138
|
+
if next_end && !(chunk.text.getbyte(next_end - 1) == 13 && next_end == chunk.text.bytesize)
|
|
139
|
+
return chunk.text.byteslice(local, next_end - local).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
140
|
+
end
|
|
141
|
+
start = prefix.bytesize + local
|
|
142
|
+
finish = row + 1 < line_count ? line_start(row + 1) : bytesize
|
|
143
|
+
text = read_bytes(start, finish - start)
|
|
144
|
+
text.sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def line_start(row)
|
|
148
|
+
_, _, prefix, local = line_location(row)
|
|
149
|
+
prefix.bytesize + local
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private def line_location(row)
|
|
153
|
+
raise RangeError, "line out of bounds" unless row.is_a?(Integer) && row.between?(0, line_count - 1)
|
|
154
|
+
return [0, tree[0], TextSummary.zero, 0] if row.zero?
|
|
155
|
+
index, chunk, prefix = tree.locate(row, :break_count, bias: :left)
|
|
156
|
+
local_row = row - prefix.break_count - 1
|
|
157
|
+
local_row += 1 if prefix.ends_with_cr? && chunk.summary.starts_with_lf?
|
|
158
|
+
local = chunk.line_ends.fetch(local_row)
|
|
159
|
+
if local == chunk.text.bytesize
|
|
160
|
+
previous = chunk.text.getbyte(local - 1)
|
|
161
|
+
prefix += chunk.summary
|
|
162
|
+
index += 1
|
|
163
|
+
chunk = tree[index]
|
|
164
|
+
local = previous == 13 && chunk&.text&.start_with?("\n") ? 1 : 0
|
|
165
|
+
end
|
|
166
|
+
[index, chunk, prefix, local]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Columns count Unicode codepoints, not bytes, graphemes, or UTF-16 code units.
|
|
170
|
+
def point_at(byte_offset)
|
|
171
|
+
total = summary_before(byte_offset)
|
|
172
|
+
Point.new(total.break_count, total.last_line_length)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def offset_at(point)
|
|
176
|
+
_, chunk, prefix, local = line_location(point.row)
|
|
177
|
+
start = prefix.bytesize + local
|
|
178
|
+
ending = chunk&.line_ends&.bsearch { |finish| finish > local }
|
|
179
|
+
text = ending ? chunk.text.byteslice(local, ending - local).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "") : line(point.row)
|
|
180
|
+
raise RangeError, "column out of bounds" unless point.column.between?(0, text.length)
|
|
181
|
+
start + text[0, point.column].bytesize
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def utf16_offset_at(byte_offset) = summary_before(byte_offset).utf16_length
|
|
185
|
+
|
|
186
|
+
def offset_at_utf16(utf16_offset)
|
|
187
|
+
raise RangeError, "UTF-16 offset out of bounds" unless utf16_offset.is_a?(Integer) && utf16_offset.between?(0, utf16_length)
|
|
188
|
+
_, chunk, prefix = tree.locate(utf16_offset, :utf16_length)
|
|
189
|
+
return bytesize unless chunk
|
|
190
|
+
units = prefix.utf16_length
|
|
191
|
+
bytes = prefix.bytesize
|
|
192
|
+
chunk.text.each_codepoint do |codepoint|
|
|
193
|
+
return bytes if units == utf16_offset
|
|
194
|
+
units += codepoint > 0xffff ? 2 : 1
|
|
195
|
+
bytes += codepoint.chr(Encoding::UTF_8).bytesize
|
|
196
|
+
raise RangeError, "UTF-16 offset splits a surrogate pair" if units > utf16_offset
|
|
197
|
+
end
|
|
198
|
+
bytes
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def utf16_point_at(byte_offset)
|
|
202
|
+
point = point_at(byte_offset)
|
|
203
|
+
column = utf16_offset_at(byte_offset) - utf16_offset_at(line_start(point.row))
|
|
204
|
+
Point.new(point.row, [column, 0].max)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def offset_at_utf16_point(point)
|
|
208
|
+
start = line_start(point.row)
|
|
209
|
+
offset = offset_at_utf16(utf16_offset_at(start) + point.column)
|
|
210
|
+
raise RangeError, "column out of bounds" if offset > start + line(point.row).bytesize
|
|
211
|
+
offset
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def anchor(byte_offset, bias: :right)
|
|
215
|
+
locate_byte_offset(byte_offset)
|
|
216
|
+
Anchor.new(byte_offset, bias: bias)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def check_invariants!
|
|
220
|
+
tree.check_invariants!
|
|
221
|
+
each_chunk { |text| raise "invalid UTF-8 chunk" unless text.valid_encoding? && text.frozen? }
|
|
222
|
+
raise "incorrect text summary" unless summary == TextSummary.from_text(to_s)
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def with_tree(tree) = self.class.new(chunk_size: chunk_size, tree: tree)
|
|
229
|
+
|
|
230
|
+
def join_trees(left, right)
|
|
231
|
+
return right if left.empty?
|
|
232
|
+
return left if right.empty?
|
|
233
|
+
boundary = chunks(left[left.size - 1].text + right[0].text)
|
|
234
|
+
left.slice(0, left.size - 1).append(boundary).append(right.slice(1, right.size - 1))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def normalize_text(text)
|
|
238
|
+
raise TypeError, "text must be a String" unless text.is_a?(String)
|
|
239
|
+
string = text.encoding == Encoding::UTF_8 ? text : text.encode(Encoding::UTF_8)
|
|
240
|
+
raise ArgumentError, "text must be valid UTF-8" unless string.valid_encoding?
|
|
241
|
+
string
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def chunks(text)
|
|
245
|
+
return [] if text.empty?
|
|
246
|
+
return [Chunk.new(text)] if text.bytesize <= chunk_size
|
|
247
|
+
result = []
|
|
248
|
+
if text.ascii_only?
|
|
249
|
+
offset = 0
|
|
250
|
+
while offset < text.bytesize
|
|
251
|
+
length = [chunk_size, text.bytesize - offset].min
|
|
252
|
+
length -= 1 if text.getbyte(offset + length - 1) == 13 && text.getbyte(offset + length) == 10
|
|
253
|
+
result << Chunk.new(text.byteslice(offset, length))
|
|
254
|
+
offset += length
|
|
255
|
+
end
|
|
256
|
+
else
|
|
257
|
+
buffer = +""
|
|
258
|
+
text.each_grapheme_cluster do |grapheme|
|
|
259
|
+
if !buffer.empty? && buffer.bytesize + grapheme.bytesize > chunk_size
|
|
260
|
+
result << Chunk.new(buffer)
|
|
261
|
+
buffer = +""
|
|
262
|
+
end
|
|
263
|
+
buffer << grapheme
|
|
264
|
+
end
|
|
265
|
+
result << Chunk.new(buffer) unless buffer.empty?
|
|
266
|
+
end
|
|
267
|
+
result
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def byte_bounds(range)
|
|
271
|
+
raise TypeError, "expected a Range of byte offsets" unless range.is_a?(Range)
|
|
272
|
+
start = range.begin || 0
|
|
273
|
+
finish = range.end.nil? ? bytesize : range.end + (range.exclude_end? ? 0 : 1)
|
|
274
|
+
unless start.is_a?(Integer) && finish.is_a?(Integer) && start >= 0 && finish >= start && finish <= bytesize
|
|
275
|
+
raise RangeError, "byte range out of bounds"
|
|
276
|
+
end
|
|
277
|
+
[start, finish]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def locate_byte_offset(offset)
|
|
281
|
+
raise RangeError, "byte offset out of bounds" unless offset.is_a?(Integer) && offset.between?(0, bytesize)
|
|
282
|
+
result = tree.locate(offset, :bytesize)
|
|
283
|
+
chunk = result[1]
|
|
284
|
+
byte = chunk&.text&.getbyte(offset - result[2].bytesize)
|
|
285
|
+
raise RangeError, "byte offset splits a UTF-8 character" if byte && (byte & 0xc0) == 0x80
|
|
286
|
+
result
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def summary_before(offset)
|
|
290
|
+
_, chunk, prefix = locate_byte_offset(offset)
|
|
291
|
+
chunk ? prefix + TextSummary.from_text(chunk.text.byteslice(0, offset - prefix.bytesize)) : prefix
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def read_bytes(offset, length)
|
|
295
|
+
return "" if length.zero?
|
|
296
|
+
index, chunk, prefix = locate_byte_offset(offset)
|
|
297
|
+
local = offset - prefix.bytesize
|
|
298
|
+
return chunk.text.byteslice(local, length) if local + length <= chunk.text.bytesize
|
|
299
|
+
text = String.new(capacity: length, encoding: Encoding::UTF_8)
|
|
300
|
+
while chunk && text.bytesize < length
|
|
301
|
+
text << chunk.text.byteslice(local, [length - text.bytesize, chunk.text.bytesize - local].min)
|
|
302
|
+
local = 0
|
|
303
|
+
index += 1
|
|
304
|
+
chunk = tree[index]
|
|
305
|
+
end
|
|
306
|
+
text
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
# The number of line breaks is additive, except CR + LF is one break.
|
|
5
|
+
class TextSummary
|
|
6
|
+
FIELDS = %i[bytesize length utf16_length break_count longest_row longest_row_length
|
|
7
|
+
first_line_length last_line_length starts_with_lf? ends_with_cr?].freeze
|
|
8
|
+
attr_reader :bytesize, :length, :utf16_length, :break_count, :longest_row,
|
|
9
|
+
:longest_row_length, :first_line_length, :last_line_length
|
|
10
|
+
|
|
11
|
+
def initialize(bytesize: 0, length: 0, utf16_length: 0, break_count: 0,
|
|
12
|
+
longest_row: 0, longest_row_length: 0, first_line_length: 0,
|
|
13
|
+
last_line_length: 0, starts_with_lf: false, ends_with_cr: false)
|
|
14
|
+
@bytesize, @length, @utf16_length, @break_count = bytesize, length, utf16_length, break_count
|
|
15
|
+
@longest_row, @longest_row_length = longest_row, longest_row_length
|
|
16
|
+
@first_line_length, @last_line_length = first_line_length, last_line_length
|
|
17
|
+
@starts_with_lf, @ends_with_cr = starts_with_lf, ends_with_cr
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
ZERO = new
|
|
22
|
+
def self.zero = ZERO
|
|
23
|
+
|
|
24
|
+
def self.from_text(text, line_ends: nil)
|
|
25
|
+
if text.ascii_only? && !text.include?("\r")
|
|
26
|
+
start = lines = first = longest = longest_row = 0
|
|
27
|
+
while (ending = text.index("\n", start))
|
|
28
|
+
width = ending - start
|
|
29
|
+
first = width if lines.zero?
|
|
30
|
+
longest, longest_row = width, lines if width > longest
|
|
31
|
+
lines += 1
|
|
32
|
+
start = ending + 1
|
|
33
|
+
line_ends << start if line_ends
|
|
34
|
+
end
|
|
35
|
+
last = text.bytesize - start
|
|
36
|
+
first = last if lines.zero?
|
|
37
|
+
longest, longest_row = last, lines if last > longest
|
|
38
|
+
return new(bytesize: text.bytesize, length: text.bytesize, utf16_length: text.bytesize,
|
|
39
|
+
break_count: lines, longest_row: longest_row, longest_row_length: longest,
|
|
40
|
+
first_line_length: first, last_line_length: last, starts_with_lf: text.start_with?("\n"))
|
|
41
|
+
end
|
|
42
|
+
rows = text.split(/\r\n|[\r\n\u2028\u2029]/, -1)
|
|
43
|
+
rows = [""] if rows.empty?
|
|
44
|
+
lengths = rows.map(&:length)
|
|
45
|
+
longest = lengths.max
|
|
46
|
+
chars = text.length
|
|
47
|
+
supplementary = text.ascii_only? ? 0 : text.scan(/[\u{10000}-\u{10ffff}]/).length
|
|
48
|
+
if line_ends
|
|
49
|
+
offset = 0
|
|
50
|
+
text.split(/(\r\n|[\r\n\u2028\u2029])/, -1).each_with_index do |part, index|
|
|
51
|
+
offset += part.bytesize
|
|
52
|
+
line_ends << offset if index.odd?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
new(bytesize: text.bytesize, length: chars, utf16_length: chars + supplementary,
|
|
56
|
+
break_count: rows.length - 1, longest_row: lengths.index(longest), longest_row_length: longest,
|
|
57
|
+
first_line_length: lengths.first, last_line_length: lengths.last,
|
|
58
|
+
starts_with_lf: text.start_with?("\n"), ends_with_cr: text.end_with?("\r"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def +(other)
|
|
62
|
+
return other if bytesize.zero?
|
|
63
|
+
return self if other.bytesize.zero?
|
|
64
|
+
overlap = ends_with_cr? && other.starts_with_lf? ? 1 : 0
|
|
65
|
+
bridge = last_line_length + other.first_line_length
|
|
66
|
+
row = longest_row
|
|
67
|
+
width = longest_row_length
|
|
68
|
+
if bridge > width
|
|
69
|
+
row, width = break_count, bridge
|
|
70
|
+
end
|
|
71
|
+
if other.longest_row_length > width
|
|
72
|
+
row, width = break_count - overlap + other.longest_row, other.longest_row_length
|
|
73
|
+
end
|
|
74
|
+
self.class.new(bytesize: bytesize + other.bytesize, length: length + other.length,
|
|
75
|
+
utf16_length: utf16_length + other.utf16_length,
|
|
76
|
+
break_count: break_count + other.break_count - overlap,
|
|
77
|
+
longest_row: row, longest_row_length: width,
|
|
78
|
+
first_line_length: break_count.zero? ? first_line_length + other.first_line_length : first_line_length,
|
|
79
|
+
last_line_length: other.break_count.zero? ? last_line_length + other.last_line_length : other.last_line_length,
|
|
80
|
+
starts_with_lf: starts_with_lf?, ends_with_cr: other.ends_with_cr?)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def ==(other) = other.is_a?(TextSummary) && FIELDS.all? { |field| public_send(field) == other.public_send(field) }
|
|
84
|
+
alias eql? ==
|
|
85
|
+
def hash = FIELDS.map { |field| public_send(field) }.hash
|
|
86
|
+
|
|
87
|
+
def starts_with_lf? = @starts_with_lf
|
|
88
|
+
def ends_with_cr? = @ends_with_cr
|
|
89
|
+
|
|
90
|
+
def project_combined(dimension, other)
|
|
91
|
+
case dimension
|
|
92
|
+
when :bytesize then bytesize + other.bytesize
|
|
93
|
+
when :length then length + other.length
|
|
94
|
+
when :utf16_length then utf16_length + other.utf16_length
|
|
95
|
+
when :break_count then break_count + other.break_count - (ends_with_cr? && other.starts_with_lf? ? 1 : 0)
|
|
96
|
+
else (self + other).public_send(dimension)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
# An immutable, ordered B+ tree. Items expose #summary; summaries expose .zero and #+.
|
|
5
|
+
class Tree
|
|
6
|
+
include Enumerable
|
|
7
|
+
DEFAULT_BRANCHING = 16
|
|
8
|
+
|
|
9
|
+
class Node
|
|
10
|
+
attr_reader :entries, :summary, :prefixes, :counts, :height, :count
|
|
11
|
+
|
|
12
|
+
def initialize(entries, height, zero)
|
|
13
|
+
@entries = entries.freeze
|
|
14
|
+
@height = height
|
|
15
|
+
total = zero
|
|
16
|
+
count = 0
|
|
17
|
+
@prefixes = entries.map { |entry| total += entry.summary }.freeze
|
|
18
|
+
@counts = entries.map { |entry| count += height.zero? ? 1 : entry.count }.freeze
|
|
19
|
+
@summary = total
|
|
20
|
+
@count = count
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def each(&block)
|
|
25
|
+
if height.zero?
|
|
26
|
+
entries.each(&block)
|
|
27
|
+
else
|
|
28
|
+
entries.each { |child| child.each(&block) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :root, :summary_class, :branching
|
|
34
|
+
protected :root, :summary_class
|
|
35
|
+
|
|
36
|
+
def initialize(items = [], summary:, branching: DEFAULT_BRANCHING, root: nil)
|
|
37
|
+
raise ArgumentError, "branching must be an even integer >= 4" unless branching.is_a?(Integer) && branching >= 4 && branching.even?
|
|
38
|
+
@summary_class = summary
|
|
39
|
+
@branching = branching
|
|
40
|
+
@zero = summary.zero
|
|
41
|
+
@root = root || build(items.to_a)
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.from(items, **options) = new(items, **options)
|
|
46
|
+
def summary = root ? root.summary : @zero
|
|
47
|
+
def size = root ? root.count : 0
|
|
48
|
+
alias length size
|
|
49
|
+
def empty? = root.nil?
|
|
50
|
+
|
|
51
|
+
def each(&block)
|
|
52
|
+
return enum_for(__method__) unless block
|
|
53
|
+
root&.each(&block)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def [](index)
|
|
58
|
+
return nil unless index.is_a?(Integer) && index >= 0 && index < size
|
|
59
|
+
node = root
|
|
60
|
+
until node.height.zero?
|
|
61
|
+
child = node.counts.bsearch_index { |count| count > index }
|
|
62
|
+
index -= child.zero? ? 0 : node.counts[child - 1]
|
|
63
|
+
node = node.entries[child]
|
|
64
|
+
end
|
|
65
|
+
node.entries[index]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def push(item) = replace_at(size, [item])
|
|
69
|
+
|
|
70
|
+
def append(other)
|
|
71
|
+
other = self.class.new(other, summary: summary_class, branching: branching) unless other.is_a?(Tree)
|
|
72
|
+
raise ArgumentError, "incompatible trees" unless summary_class == other.summary_class && branching == other.branching
|
|
73
|
+
with_root(join_roots(root, other.root))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Replace a single item, or insert at the end, copying only its ancestor path.
|
|
77
|
+
def replace_at(index, items)
|
|
78
|
+
raise RangeError, "item index out of bounds" unless index.is_a?(Integer) && index.between?(0, size)
|
|
79
|
+
values = items.to_a
|
|
80
|
+
return slice(0, index).append(values).append(slice(index + 1, size - index - 1)) if values.empty? && index < size
|
|
81
|
+
return self if values.empty?
|
|
82
|
+
if values.length > branching
|
|
83
|
+
finish = [index + 1, size].min
|
|
84
|
+
return slice(0, index).append(values).append(slice(finish, size - finish))
|
|
85
|
+
end
|
|
86
|
+
nodes = replace_node(root, index, values)
|
|
87
|
+
with_root(nodes.length == 1 ? nodes.first : node(nodes, nodes.first.height + 1))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def split_at(index)
|
|
91
|
+
validate_range(index, 0)
|
|
92
|
+
left, right = split_node(root, index)
|
|
93
|
+
[with_root(left), with_root(right)]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def slice(index, length = size - index)
|
|
97
|
+
validate_range(index, length)
|
|
98
|
+
return self if index.zero? && length == size
|
|
99
|
+
with_root(slice_node(root, index, length))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def cursor(dimension) = Cursor.new(self, dimension)
|
|
103
|
+
|
|
104
|
+
# [item index, item, cumulative summary before the item].
|
|
105
|
+
def locate(value, dimension, bias: :right)
|
|
106
|
+
raise ArgumentError, "bias must be :left or :right" unless %i[left right].include?(bias)
|
|
107
|
+
prefix = @zero
|
|
108
|
+
index = 0
|
|
109
|
+
current = root
|
|
110
|
+
while current
|
|
111
|
+
slot = current.prefixes.bsearch_index do |entry_summary|
|
|
112
|
+
measure = if prefix.respond_to?(:project_combined) && dimension.is_a?(Symbol)
|
|
113
|
+
prefix.project_combined(dimension, entry_summary)
|
|
114
|
+
else
|
|
115
|
+
project(dimension, prefix + entry_summary)
|
|
116
|
+
end
|
|
117
|
+
bias == :left ? measure >= value : measure > value
|
|
118
|
+
end
|
|
119
|
+
return [size, nil, summary] unless slot
|
|
120
|
+
prefix += current.prefixes[slot - 1] if slot.positive?
|
|
121
|
+
index += current.counts[slot - 1] if slot.positive?
|
|
122
|
+
return [index, current.entries[slot], prefix] if current.height.zero?
|
|
123
|
+
current = current.entries[slot]
|
|
124
|
+
end
|
|
125
|
+
[0, nil, @zero]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def prefix_summary(index)
|
|
129
|
+
validate_range(index, 0)
|
|
130
|
+
return summary if index == size
|
|
131
|
+
prefix = @zero
|
|
132
|
+
current = root
|
|
133
|
+
while current
|
|
134
|
+
slot = current.counts.bsearch_index { |count| count > index }
|
|
135
|
+
if slot.positive?
|
|
136
|
+
prefix += current.prefixes[slot - 1]
|
|
137
|
+
index -= current.counts[slot - 1]
|
|
138
|
+
end
|
|
139
|
+
break if current.height.zero?
|
|
140
|
+
current = current.entries[slot]
|
|
141
|
+
end
|
|
142
|
+
prefix
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Raises on an invalid occupancy, depth, count, or cached summary.
|
|
146
|
+
def check_invariants!
|
|
147
|
+
check_node(root, true) if root
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def project(dimension, summary)
|
|
154
|
+
return summary.public_send(dimension) if dimension.is_a?(Symbol) || dimension.is_a?(String)
|
|
155
|
+
return dimension.from_summary(summary) if dimension.respond_to?(:from_summary)
|
|
156
|
+
dimension.call(summary)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_range(index, length)
|
|
160
|
+
raise RangeError, "item range out of bounds" unless index.is_a?(Integer) && length.is_a?(Integer) && index >= 0 && length >= 0 && index + length <= size
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def with_root(root) = self.class.new(summary: summary_class, branching: branching, root: root)
|
|
164
|
+
def node(entries, height) = Node.new(entries, height, @zero)
|
|
165
|
+
|
|
166
|
+
def pack(entries, height)
|
|
167
|
+
return [] if entries.empty?
|
|
168
|
+
count = (entries.length + branching - 1) / branching
|
|
169
|
+
width, extra = entries.length.divmod(count)
|
|
170
|
+
offset = 0
|
|
171
|
+
Array.new(count) do |i|
|
|
172
|
+
length = width + (i < extra ? 1 : 0)
|
|
173
|
+
result = node(entries.slice(offset, length), height)
|
|
174
|
+
offset += length
|
|
175
|
+
result
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build(items)
|
|
180
|
+
level = pack(items, 0)
|
|
181
|
+
height = 1
|
|
182
|
+
while level.length > 1
|
|
183
|
+
level = pack(level, height)
|
|
184
|
+
height += 1
|
|
185
|
+
end
|
|
186
|
+
level.first
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def replace_node(current, index, values)
|
|
190
|
+
return pack(values, 0) unless current
|
|
191
|
+
entries = current.entries.dup
|
|
192
|
+
if current.height.zero?
|
|
193
|
+
entries[index, index == current.count ? 0 : 1] = values
|
|
194
|
+
else
|
|
195
|
+
slot = current.counts.bsearch_index { |count| count > index } || entries.length - 1
|
|
196
|
+
offset = slot.zero? ? 0 : current.counts[slot - 1]
|
|
197
|
+
entries[slot, 1] = replace_node(entries[slot], index - offset, values)
|
|
198
|
+
end
|
|
199
|
+
pack(entries, current.height)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def join_roots(left, right)
|
|
203
|
+
return right unless left
|
|
204
|
+
return left unless right
|
|
205
|
+
nodes = join_nodes(left, right)
|
|
206
|
+
nodes.length == 1 ? nodes.first : node(nodes, nodes.first.height + 1)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def join_nodes(left, right)
|
|
210
|
+
if left.height == right.height
|
|
211
|
+
pack(left.entries + right.entries, left.height)
|
|
212
|
+
elsif left.height > right.height
|
|
213
|
+
pack(left.entries[0...-1] + join_nodes(left.entries.last, right), left.height)
|
|
214
|
+
else
|
|
215
|
+
pack(join_nodes(left, right.entries.first) + right.entries.drop(1), right.height)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def split_node(current, index)
|
|
220
|
+
return [nil, current] if index.zero?
|
|
221
|
+
return [current, nil] if !current || index == current.count
|
|
222
|
+
return [node(current.entries.take(index), 0), node(current.entries.drop(index), 0)] if current.height.zero?
|
|
223
|
+
slot = current.counts.bsearch_index { |count| count >= index }
|
|
224
|
+
offset = slot.zero? ? 0 : current.counts[slot - 1]
|
|
225
|
+
left, right = split_node(current.entries[slot], index - offset)
|
|
226
|
+
before = current.entries.take(slot)
|
|
227
|
+
after = current.entries.drop(slot + 1)
|
|
228
|
+
prefix = before.empty? ? nil : (before.length == 1 ? before.first : node(before, current.height))
|
|
229
|
+
suffix = after.empty? ? nil : (after.length == 1 ? after.first : node(after, current.height))
|
|
230
|
+
[join_roots(prefix, left), join_roots(right, suffix)]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def slice_node(current, index, length)
|
|
234
|
+
return nil if length.zero?
|
|
235
|
+
return current if index.zero? && length == current.count
|
|
236
|
+
return node(current.entries.slice(index, length), 0) if current.height.zero?
|
|
237
|
+
slot = current.counts.bsearch_index { |count| count > index }
|
|
238
|
+
offset = slot.zero? ? 0 : current.counts[slot - 1]
|
|
239
|
+
result = nil
|
|
240
|
+
while length.positive?
|
|
241
|
+
child = current.entries[slot]
|
|
242
|
+
local = index - offset
|
|
243
|
+
count = [length, child.count - local].min
|
|
244
|
+
result = join_roots(result, slice_node(child, local, count))
|
|
245
|
+
length -= count
|
|
246
|
+
index += count
|
|
247
|
+
offset += child.count
|
|
248
|
+
slot += 1
|
|
249
|
+
end
|
|
250
|
+
result
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def check_node(current, root)
|
|
254
|
+
minimum = root ? (current.height.zero? ? 1 : 2) : branching / 2
|
|
255
|
+
raise "invalid node occupancy" unless current.entries.length.between?(minimum, branching)
|
|
256
|
+
raise "mutable node" unless current.frozen? && current.entries.frozen?
|
|
257
|
+
actual = @zero
|
|
258
|
+
count = 0
|
|
259
|
+
current.entries.each_with_index do |entry, index|
|
|
260
|
+
if current.height.positive?
|
|
261
|
+
raise "unequal leaf depth" unless entry.height == current.height - 1
|
|
262
|
+
check_node(entry, false)
|
|
263
|
+
end
|
|
264
|
+
actual += entry.summary
|
|
265
|
+
count += current.height.zero? ? 1 : entry.count
|
|
266
|
+
raise "invalid prefix" unless actual == current.prefixes[index] && count == current.counts[index]
|
|
267
|
+
end
|
|
268
|
+
raise "invalid summary" unless actual == current.summary && count == current.count
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/denebola.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "denebola/version"
|
|
4
|
+
require_relative "denebola/error"
|
|
5
|
+
require_relative "denebola/tree"
|
|
6
|
+
require_relative "denebola/cursor"
|
|
7
|
+
require_relative "denebola/text_summary"
|
|
8
|
+
require_relative "denebola/point"
|
|
9
|
+
require_relative "denebola/dimensions"
|
|
10
|
+
require_relative "denebola/rope"
|
|
11
|
+
require_relative "denebola/anchor"
|
data/sig/denebola.rbs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
module Denebola
|
|
2
|
+
VERSION: String
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
interface _Summary
|
|
7
|
+
def +: (_Summary) -> _Summary
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
interface _Item
|
|
11
|
+
def summary: () -> _Summary
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
interface _Dimension
|
|
15
|
+
def from_summary: (_Summary) -> Numeric
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
type dimension = Symbol | String | _Dimension | ^(_Summary) -> Numeric
|
|
19
|
+
|
|
20
|
+
class Tree
|
|
21
|
+
include Enumerable[_Item]
|
|
22
|
+
DEFAULT_BRANCHING: Integer
|
|
23
|
+
attr_reader branching: Integer
|
|
24
|
+
def initialize: (?Enumerable[_Item] items, summary: untyped, ?branching: Integer, ?root: Node?) -> void
|
|
25
|
+
def self.from: (Enumerable[_Item], **untyped) -> Tree
|
|
26
|
+
def summary: () -> _Summary
|
|
27
|
+
def size: () -> Integer
|
|
28
|
+
alias length size
|
|
29
|
+
def empty?: () -> bool
|
|
30
|
+
def each: () { (_Item) -> void } -> self
|
|
31
|
+
| () -> Enumerator[_Item, self]
|
|
32
|
+
def []: (Integer) -> _Item?
|
|
33
|
+
def push: (_Item) -> Tree
|
|
34
|
+
def append: (Tree | Enumerable[_Item]) -> Tree
|
|
35
|
+
def replace_at: (Integer, Enumerable[_Item]) -> Tree
|
|
36
|
+
def split_at: (Integer) -> [Tree, Tree]
|
|
37
|
+
def slice: (Integer, ?Integer) -> Tree
|
|
38
|
+
def cursor: (dimension) -> Cursor
|
|
39
|
+
def locate: (Numeric, dimension, ?bias: :left | :right) -> [Integer, _Item?, _Summary]
|
|
40
|
+
def prefix_summary: (Integer) -> _Summary
|
|
41
|
+
def check_invariants!: () -> true
|
|
42
|
+
|
|
43
|
+
class Node
|
|
44
|
+
attr_reader entries: Array[Node | _Item]
|
|
45
|
+
attr_reader summary: _Summary
|
|
46
|
+
attr_reader prefixes: Array[_Summary]
|
|
47
|
+
attr_reader counts: Array[Integer]
|
|
48
|
+
attr_reader height: Integer
|
|
49
|
+
attr_reader count: Integer
|
|
50
|
+
def initialize: (Array[Node | _Item], Integer, _Summary) -> void
|
|
51
|
+
def each: () { (_Item) -> void } -> void
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Cursor
|
|
56
|
+
attr_reader tree: Tree
|
|
57
|
+
attr_reader dimension: dimension
|
|
58
|
+
attr_reader index: Integer
|
|
59
|
+
attr_reader item: _Item?
|
|
60
|
+
attr_reader summary: _Summary
|
|
61
|
+
def initialize: (Tree, dimension) -> void
|
|
62
|
+
def seek: (Numeric, ?bias: :left | :right) -> self
|
|
63
|
+
def next: () -> _Item?
|
|
64
|
+
def prev: () -> _Item?
|
|
65
|
+
def read_until: (Numeric, ?bias: :left | :right) -> Tree
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class TextSummary
|
|
69
|
+
FIELDS: Array[Symbol]
|
|
70
|
+
ZERO: TextSummary
|
|
71
|
+
attr_reader bytesize: Integer
|
|
72
|
+
attr_reader length: Integer
|
|
73
|
+
attr_reader utf16_length: Integer
|
|
74
|
+
attr_reader break_count: Integer
|
|
75
|
+
attr_reader longest_row: Integer
|
|
76
|
+
attr_reader longest_row_length: Integer
|
|
77
|
+
attr_reader first_line_length: Integer
|
|
78
|
+
attr_reader last_line_length: Integer
|
|
79
|
+
def initialize: (**untyped) -> void
|
|
80
|
+
def self.zero: () -> TextSummary
|
|
81
|
+
def self.from_text: (String, ?line_ends: Array[Integer]?) -> TextSummary
|
|
82
|
+
def +: (TextSummary) -> TextSummary
|
|
83
|
+
def ==: (untyped) -> bool
|
|
84
|
+
alias eql? ==
|
|
85
|
+
def hash: () -> Integer
|
|
86
|
+
def starts_with_lf?: () -> bool
|
|
87
|
+
def ends_with_cr?: () -> bool
|
|
88
|
+
def project_combined: (Symbol, TextSummary) -> untyped
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Point
|
|
92
|
+
attr_reader row: Integer
|
|
93
|
+
attr_reader column: Integer
|
|
94
|
+
def initialize: (?Integer, ?Integer, ?row: Integer, ?column: Integer) -> void
|
|
95
|
+
def ==: (untyped) -> bool
|
|
96
|
+
alias eql? ==
|
|
97
|
+
def hash: () -> Integer
|
|
98
|
+
def to_a: () -> [Integer, Integer]
|
|
99
|
+
def inspect: () -> String
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
module Dimensions
|
|
103
|
+
BYTES: ^(TextSummary) -> Integer
|
|
104
|
+
CHARACTERS: ^(TextSummary) -> Integer
|
|
105
|
+
UTF16: ^(TextSummary) -> Integer
|
|
106
|
+
LINE_BREAKS: ^(TextSummary) -> Integer
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Rope
|
|
110
|
+
DEFAULT_CHUNK_SIZE: Integer
|
|
111
|
+
LINE_BREAK: Regexp
|
|
112
|
+
attr_reader chunk_size: Integer
|
|
113
|
+
def initialize: (?String, ?chunk_size: Integer, ?branching: Integer, ?tree: Tree?) -> void
|
|
114
|
+
def summary: () -> TextSummary
|
|
115
|
+
def bytesize: () -> Integer
|
|
116
|
+
def length: () -> Integer
|
|
117
|
+
alias size length
|
|
118
|
+
def utf16_length: () -> Integer
|
|
119
|
+
def line_count: () -> Integer
|
|
120
|
+
def empty?: () -> bool
|
|
121
|
+
def each_chunk: () { (String) -> void } -> self
|
|
122
|
+
| () -> Enumerator[String, self]
|
|
123
|
+
def to_s: () -> String
|
|
124
|
+
def insert: (Integer, String) -> Rope
|
|
125
|
+
def delete: (Range[Integer?]) -> Rope
|
|
126
|
+
def replace: (Range[Integer?], String) -> Rope
|
|
127
|
+
def apply_edits: (Array[[Range[Integer?], String]]) -> Rope
|
|
128
|
+
def byteslice: (Integer | Range[Integer?], ?Integer?) -> Rope
|
|
129
|
+
def split_at: (Integer) -> [Rope, Rope]
|
|
130
|
+
def line: (Integer) -> String
|
|
131
|
+
def line_start: (Integer) -> Integer
|
|
132
|
+
def point_at: (Integer) -> Point
|
|
133
|
+
def offset_at: (Point) -> Integer
|
|
134
|
+
def utf16_offset_at: (Integer) -> Integer
|
|
135
|
+
def offset_at_utf16: (Integer) -> Integer
|
|
136
|
+
def utf16_point_at: (Integer) -> Point
|
|
137
|
+
def offset_at_utf16_point: (Point) -> Integer
|
|
138
|
+
def anchor: (Integer, ?bias: :left | :right) -> Anchor
|
|
139
|
+
def check_invariants!: () -> true
|
|
140
|
+
|
|
141
|
+
class Chunk
|
|
142
|
+
attr_reader text: String
|
|
143
|
+
attr_reader summary: TextSummary
|
|
144
|
+
attr_reader line_ends: Array[Integer]
|
|
145
|
+
def initialize: (String) -> void
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class Anchor
|
|
150
|
+
attr_reader offset: Integer
|
|
151
|
+
attr_reader bias: :left | :right
|
|
152
|
+
def initialize: (Integer, ?bias: :left | :right) -> void
|
|
153
|
+
def transform: (Array[[Range[Integer], String]]) -> Anchor
|
|
154
|
+
end
|
|
155
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: denebola
|
|
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: Immutable, structurally shared trees with summary dimensions, UTF-8 text
|
|
13
|
+
editing, line and UTF-16 indexing, and anchors.
|
|
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
|
+
- lib/denebola.rb
|
|
24
|
+
- lib/denebola/anchor.rb
|
|
25
|
+
- lib/denebola/cursor.rb
|
|
26
|
+
- lib/denebola/dimensions.rb
|
|
27
|
+
- lib/denebola/error.rb
|
|
28
|
+
- lib/denebola/point.rb
|
|
29
|
+
- lib/denebola/rope.rb
|
|
30
|
+
- lib/denebola/text_summary.rb
|
|
31
|
+
- lib/denebola/tree.rb
|
|
32
|
+
- lib/denebola/version.rb
|
|
33
|
+
- sig/denebola.rbs
|
|
34
|
+
homepage: https://github.com/noxdea/denebola
|
|
35
|
+
licenses:
|
|
36
|
+
- MIT
|
|
37
|
+
metadata:
|
|
38
|
+
allowed_push_host: https://rubygems.org
|
|
39
|
+
source_code_uri: https://github.com/noxdea/denebola
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.1'
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 4.0.19
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Persistent summary B+ trees and Unicode text ropes in pure Ruby
|
|
58
|
+
test_files: []
|