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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-10
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Denebola
4
+ class Error < StandardError; end
5
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Denebola
4
+ VERSION = "0.1.0"
5
+ 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: []