denebola 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +4 -1
- data/README.md +38 -1
- data/lib/denebola/sheet.rb +796 -0
- data/lib/denebola/sheet_range_index.rb +327 -0
- data/lib/denebola/version.rb +1 -1
- data/lib/denebola.rb +2 -0
- data/sig/denebola.rbs +30 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84880d06ec622bb6081d11f260b1e64cbfba37a9480a717208356afbfcc28108
|
|
4
|
+
data.tar.gz: 5e7c2cba1e6fb6ecfd5f6cc49c517ff038ca09a85dc81da268e69fe74142172d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 26cf63ddd365e57f19af10f195db576f21de2e7c1cf865025c6c868cfdc876f33fe8bc055366513106b68b666973eb9cd7a596bd2856273e2de23e358ff22627
|
|
7
|
+
data.tar.gz: b26f9e6372cd7dea2413c82e29996692bca7a810f473b24cdb1fbe13e31e1702746f541b4d0a73d432880733bad5a149d6f42ea31d56c7b3ef2a2d271c4cdec4
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -17,17 +17,19 @@
|
|
|
17
17
|
<a href="#installation">Installation</a> ·
|
|
18
18
|
<a href="#quick-start">Quick Start</a> ·
|
|
19
19
|
<a href="#text-rope">Text Rope</a> ·
|
|
20
|
+
<a href="#sparse-sheet">Sparse Sheet</a> ·
|
|
20
21
|
<a href="#generic-summary-tree">Summary Tree</a> ·
|
|
21
22
|
<a href="#benchmarks">Benchmarks</a>
|
|
22
23
|
</p>
|
|
23
24
|
|
|
24
25
|
---
|
|
25
26
|
|
|
26
|
-
Denebola is a library for immutable, structurally shared
|
|
27
|
+
Denebola is a library for immutable, structurally shared data. It provides a generic summary B+ tree, a Unicode-aware text rope, bounded-memory file editing, and sparse two-dimensional sheets. Every edit returns a new value while reusing untouched subtrees, so retaining a snapshot is an ordinary assignment.
|
|
27
28
|
|
|
28
29
|
## Features
|
|
29
30
|
|
|
30
31
|
- Persistent text editing with structural sharing
|
|
32
|
+
- Persistent sparse 2D sheets with range summaries and batched cell updates
|
|
31
33
|
- Bounded-memory, file-backed editing for multi-gigabyte text
|
|
32
34
|
- UTF-8 byte, Unicode codepoint, UTF-16, and line-based indexing
|
|
33
35
|
- Batched edits and explicit anchor transformation
|
|
@@ -150,6 +152,33 @@ anchor.offset # => 5
|
|
|
150
152
|
|
|
151
153
|
`: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. Transformation returns a new anchor; it does not mutate the original or automatically observe a rope.
|
|
152
154
|
|
|
155
|
+
## Sparse Sheet
|
|
156
|
+
|
|
157
|
+
`Sheet` stores only populated cells in persistent B+ trees. Empty row and column runs are represented by gaps, so the primary storage can split and join axis ranges without shifting cell nodes. Structural edits currently rebuild the derived 2D summary index from populated cells. Every update returns a new snapshot; `snapshot` returns the same immutable value in O(1).
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
sheet = Denebola::Sheet.new
|
|
161
|
+
sheet = sheet.set(0, 0, 12).set(4, 2, 30)
|
|
162
|
+
previous = sheet.snapshot
|
|
163
|
+
|
|
164
|
+
sheet[4, 2] # => 30
|
|
165
|
+
sheet.summary(0, 0, 4, 2).sum # => 42
|
|
166
|
+
sheet.each_in(0, 0, 4, 2).to_a # => [[Denebola::Point.new(0, 0), 12], [Denebola::Point.new(4, 2), 30]]
|
|
167
|
+
sheet = sheet.insert_rows(2, 1).delete(0, 0)
|
|
168
|
+
previous[0, 0] # => 12
|
|
169
|
+
|
|
170
|
+
sheet = Denebola::Sheet.new.set_many([[0, 0, 12], [0, 1, 8], [1_000_000, 3, "far"]])
|
|
171
|
+
sheet[1_000_000, 3] # => "far"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Coordinates are zero-based, and `each_in`/`summary` use inclusive bounds. `row_count` and `column_count` describe the current grid extent: setting a distant cell and inserting an axis extend it, clearing a cell preserves it, and deleting rows or columns shrinks it. `set(row, column, nil)` is equivalent to `delete`. Strings are copied and frozen; other values should be immutable to preserve snapshots. `summary` counts populated cells, sums Numeric values, reports comparable Numeric minimum/maximum, and counts values by Ruby class in `types`.
|
|
175
|
+
|
|
176
|
+
`each_in` yields `(Denebola::Point, value)`, so it can directly back a cell-source callback that accepts `(reference, value)`.
|
|
177
|
+
|
|
178
|
+
`set_many` accepts an enumerable of `[row, column, value]` edits, uses the last edit for duplicate coordinates, and returns one persistent snapshot. It bulk-builds the row tree after sorting the batch, avoiding a row-tree path rebuild for every cell; multi-column sheets also update the derived range index, while single-column sheets need no index. Untouched row objects and older snapshots remain reusable. Use `set` for isolated edits.
|
|
179
|
+
|
|
180
|
+
Whole-row summaries use the outer row-tree summary without visiting rows or cells. Arbitrary rectangles use a persistent 2D range index: a partial-column query visits O(log rows × log columns) summary nodes and does not enumerate rows or cells. Single-column sheets need no 2D index because every valid column range is the full width. Wider sheets maintain the index, building it when first widened from one column; point edits copy affected paths, and maintaining exact numeric extrema after deletion adds a logarithmic value-index update. Structural row/column insertion and deletion still rebuild this auxiliary index from populated cells, even though the primary sheet trees retain sparse gaps.
|
|
181
|
+
|
|
153
182
|
## Generic Summary Tree
|
|
154
183
|
|
|
155
184
|
Items expose `summary`. The summary class supplies `.zero` and `#+`, with associative addition and an 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.
|
|
@@ -189,6 +218,14 @@ Text dimensions are available as `Denebola::Dimensions::BYTES`, `CHARACTERS`, `U
|
|
|
189
218
|
bundle exec rake bench
|
|
190
219
|
```
|
|
191
220
|
|
|
221
|
+
The sheet benchmark builds a sparse grid and reports build, snapshot, full-row summary, and partial-column summary times. `bench/sheet_range_index.rb` measures partial-column query scaling across sparse rows:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
bundle exec ruby -Ilib bench/sheet_range_index.rb
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Measured 2026-09-23 on arm64 macOS, Ruby 4.0.6 without YJIT: batches of 2,000 / 10,000 / 40,000 populated cells (1,000 / 5,000 / 20,000 occupied rows) built in 0.283 / 1.762 / 9.162 s. A one-column summary took 72.69 / 66.33 / 154.42 µs over 500 queries per size. Query time grows with tree depth rather than linearly with selected rows. This workload also shows the 2D index's material build/update cost; million-cell workloads have not yet been remeasured with the index enabled.
|
|
228
|
+
|
|
192
229
|
The benchmark 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. The defaults are fanout **16** and chunk size **1024 bytes**: smaller chunks improve some edits but allocate more nodes, while these defaults meet the edit and retained-memory budgets together. Override them with `Rope.new(text, branching: 8, chunk_size: 512)`.
|
|
193
230
|
|
|
194
231
|
Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
|
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
class Sheet
|
|
5
|
+
# Maps current axis coordinates to stable RangeIndex coordinates. Structural
|
|
6
|
+
# edits split or remove these runs without moving unaffected index entries.
|
|
7
|
+
# ponytail: flat runs make edits O(S) and rectangle queries O(Sr×Sc); use a persistent interval tree if fragmented-axis workloads dominate.
|
|
8
|
+
class IndexAxis
|
|
9
|
+
Segment = Struct.new(:length, :index_start, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
attr_reader :extent, :next_index, :segments
|
|
12
|
+
|
|
13
|
+
def initialize(segments: [], extent: 0, next_index: 0)
|
|
14
|
+
@segments = segments.freeze
|
|
15
|
+
@extent, @next_index = extent, next_index
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.identity(extent)
|
|
20
|
+
new(segments: extent.positive? ? [segment(extent, 0)] : [], extent: extent, next_index: extent)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ensure_length(length)
|
|
24
|
+
return self if length == extent
|
|
25
|
+
raise ArgumentError, "axis length cannot shrink" unless length > extent
|
|
26
|
+
|
|
27
|
+
append(length - extent)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def index_at(position)
|
|
31
|
+
raise RangeError, "axis position out of bounds" unless position.is_a?(Integer) && position.between?(0, extent - 1)
|
|
32
|
+
|
|
33
|
+
offset = 0
|
|
34
|
+
segments.each do |segment|
|
|
35
|
+
return segment.index_start + position - offset if position < offset + segment.length
|
|
36
|
+
offset += segment.length
|
|
37
|
+
end
|
|
38
|
+
raise "axis mapping is incomplete"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ranges(first, last)
|
|
42
|
+
return [] if first > last || first >= extent
|
|
43
|
+
|
|
44
|
+
last = [last, extent - 1].min
|
|
45
|
+
offset = 0
|
|
46
|
+
segments.filter_map do |segment|
|
|
47
|
+
finish = offset + segment.length - 1
|
|
48
|
+
from = [first, offset].max
|
|
49
|
+
to = [last, finish].min
|
|
50
|
+
mapped = [segment.index_start + from - offset, segment.index_start + to - offset] if from <= to
|
|
51
|
+
offset = finish + 1
|
|
52
|
+
mapped
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def insert(at, count)
|
|
57
|
+
return self if count.zero?
|
|
58
|
+
|
|
59
|
+
left, right = split(at)
|
|
60
|
+
self.class.new(segments: coalesce(left + [self.class.segment(count, next_index)] + right),
|
|
61
|
+
extent: extent + count, next_index: next_index + count)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def delete(at, count)
|
|
65
|
+
count = [count, extent - at].min
|
|
66
|
+
return self if count.zero?
|
|
67
|
+
|
|
68
|
+
left, tail = split(at)
|
|
69
|
+
_removed, right = split_segments(tail, count)
|
|
70
|
+
self.class.new(segments: coalesce(left + right), extent: extent - count, next_index: next_index)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_invariants!
|
|
74
|
+
raise "invalid axis extent" unless segments.sum(&:length) == extent
|
|
75
|
+
raise "invalid axis segment" unless segments.all? { |segment| segment.length.positive? && segment.index_start >= 0 }
|
|
76
|
+
raise "coalescible axis segments" if segments.each_cons(2).any? { |left, right| left.index_start + left.length == right.index_start }
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def append(length)
|
|
83
|
+
self.class.new(segments: coalesce(segments + [self.class.segment(length, next_index)]),
|
|
84
|
+
extent: extent + length, next_index: next_index + length)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def split(position)
|
|
88
|
+
raise RangeError, "axis position out of bounds" unless position.between?(0, extent)
|
|
89
|
+
left, right = split_segments(segments, position)
|
|
90
|
+
[left, right]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def split_segments(items, length)
|
|
94
|
+
left = []
|
|
95
|
+
right = []
|
|
96
|
+
offset = 0
|
|
97
|
+
items.each do |segment|
|
|
98
|
+
before = [[length - offset, 0].max, segment.length].min
|
|
99
|
+
left << self.class.segment(before, segment.index_start) if before.positive?
|
|
100
|
+
after = segment.length - before
|
|
101
|
+
right << self.class.segment(after, segment.index_start + before) if after.positive?
|
|
102
|
+
offset += segment.length
|
|
103
|
+
end
|
|
104
|
+
[left, right]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def coalesce(items)
|
|
108
|
+
items.each_with_object([]) do |segment, result|
|
|
109
|
+
previous = result.last
|
|
110
|
+
if previous && previous.index_start + previous.length == segment.index_start
|
|
111
|
+
result[-1] = self.class.segment(previous.length + segment.length, previous.index_start)
|
|
112
|
+
else
|
|
113
|
+
result << segment
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.segment(length, index_start) = Segment.new(length:, index_start:).freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class Summary
|
|
122
|
+
attr_reader :count, :sum, :min, :max, :types
|
|
123
|
+
|
|
124
|
+
def initialize(count: 0, sum: 0, min: nil, max: nil, types: {})
|
|
125
|
+
@count, @sum, @min, @max = count, sum, min, max
|
|
126
|
+
@types = types.freeze
|
|
127
|
+
freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
ZERO = new
|
|
131
|
+
def self.zero = ZERO
|
|
132
|
+
def self.value(value)
|
|
133
|
+
numeric = value.is_a?(Numeric) && (value <=> value)
|
|
134
|
+
new(count: 1, sum: value.is_a?(Numeric) ? value : 0,
|
|
135
|
+
min: numeric ? value : nil, max: numeric ? value : nil,
|
|
136
|
+
types: { value.class => 1 })
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def +(other)
|
|
140
|
+
types = @types.dup
|
|
141
|
+
other.types.each { |type, count| types[type] = types.fetch(type, 0) + count }
|
|
142
|
+
types.freeze
|
|
143
|
+
self.class.new(count: count + other.count, sum: sum + other.sum,
|
|
144
|
+
min: extreme(min, other.min, :min), max: extreme(max, other.max, :max),
|
|
145
|
+
types: types)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ==(other) = other.is_a?(Summary) && [count, sum, min, max, types] == [other.count, other.sum, other.min, other.max, other.types]
|
|
149
|
+
alias eql? ==
|
|
150
|
+
def hash = [count, sum, min, max, types].hash
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def extreme(left, right, direction)
|
|
155
|
+
return right if left.nil?
|
|
156
|
+
return left if right.nil?
|
|
157
|
+
comparison = left <=> right
|
|
158
|
+
return nil unless comparison
|
|
159
|
+
direction == :min ? (comparison <= 0 ? left : right) : (comparison >= 0 ? left : right)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class ColumnSummary
|
|
164
|
+
attr_reader :values, :column_count
|
|
165
|
+
def initialize(values: Summary.zero, column_count: 0)
|
|
166
|
+
@values, @column_count = values, column_count
|
|
167
|
+
freeze
|
|
168
|
+
end
|
|
169
|
+
ZERO = new
|
|
170
|
+
def self.zero = ZERO
|
|
171
|
+
def +(other) = self.class.new(values: values + other.values, column_count: column_count + other.column_count)
|
|
172
|
+
def ==(other) = other.is_a?(ColumnSummary) && values == other.values && column_count == other.column_count
|
|
173
|
+
alias eql? ==
|
|
174
|
+
def hash = [values, column_count].hash
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class RowSummary
|
|
178
|
+
attr_reader :values, :row_count
|
|
179
|
+
def initialize(values: Summary.zero, row_count: 0)
|
|
180
|
+
@values, @row_count = values, row_count
|
|
181
|
+
freeze
|
|
182
|
+
end
|
|
183
|
+
ZERO = new
|
|
184
|
+
def self.zero = ZERO
|
|
185
|
+
def +(other) = self.class.new(values: values + other.values, row_count: row_count + other.row_count)
|
|
186
|
+
def ==(other) = other.is_a?(RowSummary) && values == other.values && row_count == other.row_count
|
|
187
|
+
alias eql? ==
|
|
188
|
+
def hash = [values, row_count].hash
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class Gap
|
|
192
|
+
attr_reader :length
|
|
193
|
+
def initialize(length, axis:)
|
|
194
|
+
raise ArgumentError, "gap length must be positive" unless length.is_a?(Integer) && length.positive?
|
|
195
|
+
@length, @axis = length, axis
|
|
196
|
+
@summary = axis == :row ? RowSummary.new(row_count: length) : ColumnSummary.new(column_count: length)
|
|
197
|
+
freeze
|
|
198
|
+
end
|
|
199
|
+
attr_reader :summary
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class Cell
|
|
203
|
+
attr_reader :value
|
|
204
|
+
def initialize(value)
|
|
205
|
+
@value = value.is_a?(String) ? value.dup.freeze : value
|
|
206
|
+
@summary = ColumnSummary.new(values: Summary.value(@value), column_count: 1)
|
|
207
|
+
freeze
|
|
208
|
+
end
|
|
209
|
+
attr_reader :summary
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
class Row
|
|
213
|
+
attr_reader :columns
|
|
214
|
+
def initialize(columns)
|
|
215
|
+
@columns = columns
|
|
216
|
+
@summary = RowSummary.new(values: columns.summary.values, row_count: 1)
|
|
217
|
+
freeze
|
|
218
|
+
end
|
|
219
|
+
attr_reader :summary
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
DEFAULT_BRANCHING = Tree::DEFAULT_BRANCHING
|
|
223
|
+
private_constant :IndexAxis, :ColumnSummary, :RowSummary, :Gap, :Cell, :Row
|
|
224
|
+
|
|
225
|
+
attr_reader :row_count, :column_count, :cell_count
|
|
226
|
+
|
|
227
|
+
def self.from_rows(rows, column_count, branching, range_index = nil, row_axis = nil, column_axis = nil)
|
|
228
|
+
sheet = allocate
|
|
229
|
+
sheet.instance_variable_set(:@branching, branching)
|
|
230
|
+
sheet.__send__(:initialize_state, rows, column_count, range_index, row_axis, column_axis)
|
|
231
|
+
end
|
|
232
|
+
private_class_method :from_rows
|
|
233
|
+
|
|
234
|
+
def initialize(branching: DEFAULT_BRANCHING)
|
|
235
|
+
raise ArgumentError, "branching must be an even integer >= 4" unless branching.is_a?(Integer) && branching >= 4 && branching.even?
|
|
236
|
+
@branching = branching
|
|
237
|
+
initialize_state(Tree.new(summary: RowSummary, branching: branching), 0, nil)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def initialize_state(rows, column_count, range_index, row_axis = nil, column_axis = nil)
|
|
241
|
+
raise ArgumentError, "column_count must be a non-negative integer" unless column_count.is_a?(Integer) && column_count >= 0
|
|
242
|
+
@rows = rows
|
|
243
|
+
@row_count = @rows.summary.row_count
|
|
244
|
+
@column_count = column_count
|
|
245
|
+
@cell_count = @rows.summary.values.count
|
|
246
|
+
@range_index = range_index
|
|
247
|
+
@row_axis = row_axis || IndexAxis.identity(@row_count)
|
|
248
|
+
@column_axis = column_axis || IndexAxis.identity(@column_count)
|
|
249
|
+
raise ArgumentError, "row-axis extent differs from sheet" unless @row_axis.extent == @row_count
|
|
250
|
+
raise ArgumentError, "column-axis extent differs from sheet" unless @column_axis.extent == @column_count
|
|
251
|
+
freeze
|
|
252
|
+
end
|
|
253
|
+
private :initialize_state
|
|
254
|
+
|
|
255
|
+
def [](row, column)
|
|
256
|
+
validate_coordinate(row, column)
|
|
257
|
+
_index, item, prefix = @rows.locate(row, :row_count)
|
|
258
|
+
return nil unless item.is_a?(Row) && row == prefix.row_count
|
|
259
|
+
column_at(item.columns, column)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def set(row, column, value)
|
|
263
|
+
validate_coordinate(row, column)
|
|
264
|
+
return delete(row, column) if value.nil?
|
|
265
|
+
rows = row >= row_count ? insert_axis(@rows, row_count, row - row_count + 1, :row) : @rows
|
|
266
|
+
before, tail = split_axis(rows, row, :row)
|
|
267
|
+
current, after = split_axis(tail, 1, :row)
|
|
268
|
+
item = current[0]
|
|
269
|
+
item = Row.new(empty_columns) unless item.is_a?(Row)
|
|
270
|
+
columns = set_column(item.columns, column, value)
|
|
271
|
+
updated = Row.new(columns)
|
|
272
|
+
rows = concat(concat(before, tree([updated], RowSummary)), after)
|
|
273
|
+
new_column_count = [@column_count, column + 1].max
|
|
274
|
+
row_axis = @row_axis.ensure_length([row_count, row + 1].max)
|
|
275
|
+
column_axis = @column_axis.ensure_length(new_column_count)
|
|
276
|
+
index = if new_column_count <= 1
|
|
277
|
+
nil
|
|
278
|
+
elsif @range_index
|
|
279
|
+
@range_index.update(row_axis.index_at(row), column_axis.index_at(column), self[row, column], value)
|
|
280
|
+
else
|
|
281
|
+
RangeIndex.from_rows(rows, row_axis, column_axis)
|
|
282
|
+
end
|
|
283
|
+
with_rows(rows, new_column_count, index, row_axis, column_axis)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Apply [row, column, value] edits against one snapshot; the last duplicate wins.
|
|
287
|
+
def set_many(changes)
|
|
288
|
+
grouped = {}
|
|
289
|
+
changes.each do |change|
|
|
290
|
+
raise ArgumentError, "each cell change must contain row, column, and value" unless change.respond_to?(:length) && change.length == 3
|
|
291
|
+
|
|
292
|
+
row, column, value = change
|
|
293
|
+
validate_coordinate(row, column)
|
|
294
|
+
(grouped[row] ||= {})[column] = value
|
|
295
|
+
end
|
|
296
|
+
return self if grouped.empty?
|
|
297
|
+
|
|
298
|
+
updates = []
|
|
299
|
+
new_row_count = row_count
|
|
300
|
+
new_column_count = column_count
|
|
301
|
+
grouped.sort.each do |row, values|
|
|
302
|
+
edits = values.sort
|
|
303
|
+
has_value = false
|
|
304
|
+
edits.each do |column, value|
|
|
305
|
+
next if value.nil?
|
|
306
|
+
|
|
307
|
+
has_value = true
|
|
308
|
+
new_row_count = row + 1 if row + 1 > new_row_count
|
|
309
|
+
new_column_count = column + 1 if column + 1 > new_column_count
|
|
310
|
+
end
|
|
311
|
+
updates << [row, edits] if row < row_count || has_value
|
|
312
|
+
end
|
|
313
|
+
return self if updates.empty?
|
|
314
|
+
|
|
315
|
+
result = []
|
|
316
|
+
update_index = 0
|
|
317
|
+
position = 0
|
|
318
|
+
changed = false
|
|
319
|
+
|
|
320
|
+
@rows.each do |item|
|
|
321
|
+
if item.is_a?(Gap)
|
|
322
|
+
finish = position + item.length
|
|
323
|
+
while update_index < updates.length && updates[update_index][0] < finish
|
|
324
|
+
row, edits = updates[update_index]
|
|
325
|
+
append_gap(result, row - position) if row > position
|
|
326
|
+
updated = updated_row(nil, edits)
|
|
327
|
+
append_row(result, updated)
|
|
328
|
+
changed ||= !updated.nil?
|
|
329
|
+
position = row + 1
|
|
330
|
+
update_index += 1
|
|
331
|
+
end
|
|
332
|
+
append_gap(result, finish - position) if finish > position
|
|
333
|
+
position = finish
|
|
334
|
+
else
|
|
335
|
+
if update_index < updates.length && updates[update_index][0] == position
|
|
336
|
+
updated = updated_row(item, updates[update_index][1])
|
|
337
|
+
append_row(result, updated)
|
|
338
|
+
changed ||= !updated.equal?(item)
|
|
339
|
+
update_index += 1
|
|
340
|
+
else
|
|
341
|
+
result << item
|
|
342
|
+
end
|
|
343
|
+
position += 1
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
while update_index < updates.length
|
|
348
|
+
row, edits = updates[update_index]
|
|
349
|
+
append_gap(result, row - position) if row > position
|
|
350
|
+
updated = updated_row(nil, edits)
|
|
351
|
+
append_row(result, updated)
|
|
352
|
+
changed ||= !updated.nil?
|
|
353
|
+
position = row + 1
|
|
354
|
+
update_index += 1
|
|
355
|
+
end
|
|
356
|
+
append_gap(result, new_row_count - position) if new_row_count > position
|
|
357
|
+
return self unless changed || new_row_count != row_count || new_column_count != column_count
|
|
358
|
+
|
|
359
|
+
rows = tree(result, RowSummary)
|
|
360
|
+
row_axis = @row_axis.ensure_length(new_row_count)
|
|
361
|
+
column_axis = @column_axis.ensure_length(new_column_count)
|
|
362
|
+
index = if new_column_count <= 1
|
|
363
|
+
nil
|
|
364
|
+
elsif @range_index
|
|
365
|
+
range_index = @range_index
|
|
366
|
+
updates.each do |row, edits|
|
|
367
|
+
edits.each do |column, value|
|
|
368
|
+
old_value = self[row, column]
|
|
369
|
+
if old_value != value
|
|
370
|
+
range_index = range_index.update(row_axis.index_at(row), column_axis.index_at(column), old_value, value)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
range_index
|
|
375
|
+
else
|
|
376
|
+
RangeIndex.from_rows(rows, row_axis, column_axis)
|
|
377
|
+
end
|
|
378
|
+
with_rows(rows, new_column_count, index, row_axis, column_axis)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def delete(row, column)
|
|
382
|
+
validate_coordinate(row, column)
|
|
383
|
+
return self if row >= row_count || column >= column_count
|
|
384
|
+
before, tail = split_axis(@rows, row, :row)
|
|
385
|
+
current, after = split_axis(tail, 1, :row)
|
|
386
|
+
item = current[0]
|
|
387
|
+
return self unless item.is_a?(Row)
|
|
388
|
+
columns = delete_column(item.columns, column)
|
|
389
|
+
return self if columns.equal?(item.columns)
|
|
390
|
+
replacement = columns.summary.values.count.zero? ? Gap.new(1, axis: :row) : Row.new(columns)
|
|
391
|
+
rows = concat(concat(before, tree([replacement], RowSummary)), after)
|
|
392
|
+
index = if column_count <= 1
|
|
393
|
+
nil
|
|
394
|
+
elsif @range_index
|
|
395
|
+
@range_index.update(@row_axis.index_at(row), @column_axis.index_at(column), self[row, column], nil)
|
|
396
|
+
else
|
|
397
|
+
RangeIndex.from_rows(rows, @row_axis, @column_axis)
|
|
398
|
+
end
|
|
399
|
+
with_rows(rows, column_count, index)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def each_in(top, left, bottom, right)
|
|
403
|
+
return enum_for(__method__, top, left, bottom, right) unless block_given?
|
|
404
|
+
validate_range(top, left, bottom, right)
|
|
405
|
+
return self if top >= row_count || left >= column_count
|
|
406
|
+
_, tail = split_axis(@rows, top, :row)
|
|
407
|
+
rows, = split_axis(tail, [bottom + 1 - top, row_count - top].min, :row)
|
|
408
|
+
row = top
|
|
409
|
+
rows.each do |item|
|
|
410
|
+
if item.is_a?(Gap)
|
|
411
|
+
row += item.length
|
|
412
|
+
next
|
|
413
|
+
end
|
|
414
|
+
each_column(item.columns, left, right) { |column, value| yield Point.new(row, column), value }
|
|
415
|
+
row += 1
|
|
416
|
+
end
|
|
417
|
+
self
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def summary(top, left, bottom, right)
|
|
421
|
+
validate_range(top, left, bottom, right)
|
|
422
|
+
return Summary.zero if top >= row_count || left >= column_count
|
|
423
|
+
_, tail = split_axis(@rows, top, :row)
|
|
424
|
+
rows, = split_axis(tail, [bottom + 1 - top, row_count - top].min, :row)
|
|
425
|
+
return rows.summary.values if left.zero? && right >= column_count - 1
|
|
426
|
+
raise "missing 2D range index for partial summary" unless @range_index
|
|
427
|
+
row_ranges = @row_axis.ranges(top, bottom)
|
|
428
|
+
column_ranges = @column_axis.ranges(left, right)
|
|
429
|
+
row_ranges.sum(Summary.zero) do |row_first, row_last|
|
|
430
|
+
column_ranges.sum(Summary.zero) do |column_first, column_last|
|
|
431
|
+
@range_index.summary(row_first, column_first, row_last, column_last)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def insert_rows(at, count)
|
|
437
|
+
validate_insert(at, count, row_count)
|
|
438
|
+
return self if count.zero?
|
|
439
|
+
before, after = split_axis(@rows, at, :row)
|
|
440
|
+
inserted = tree([Gap.new(count, axis: :row)], RowSummary)
|
|
441
|
+
rows = concat(concat(before, inserted), after)
|
|
442
|
+
row_axis = @row_axis.insert(at, count)
|
|
443
|
+
index = @range_index || RangeIndex.new if column_count > 1
|
|
444
|
+
with_rows(rows, column_count, index, row_axis)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def delete_rows(at, count)
|
|
448
|
+
validate_delete(at, count, row_count)
|
|
449
|
+
return self if count.zero? || at == row_count
|
|
450
|
+
before, tail = split_axis(@rows, at, :row)
|
|
451
|
+
removed_count = [count, row_count - at].min
|
|
452
|
+
removed, after = split_axis(tail, removed_count, :row)
|
|
453
|
+
rows = concat(before, after)
|
|
454
|
+
index = @range_index
|
|
455
|
+
if index && column_count > 1
|
|
456
|
+
row = at
|
|
457
|
+
removed.each do |item|
|
|
458
|
+
if item.is_a?(Gap)
|
|
459
|
+
row += item.length
|
|
460
|
+
next
|
|
461
|
+
end
|
|
462
|
+
row_index = @row_axis.index_at(row)
|
|
463
|
+
column = 0
|
|
464
|
+
item.columns.each do |cell|
|
|
465
|
+
if cell.is_a?(Cell)
|
|
466
|
+
index = index.update(row_index, @column_axis.index_at(column), cell.value, nil)
|
|
467
|
+
column += 1
|
|
468
|
+
else
|
|
469
|
+
column += cell.length
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
row += 1
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
row_axis = @row_axis.delete(at, count)
|
|
476
|
+
with_rows(rows, column_count, index, row_axis)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def insert_columns(at, count)
|
|
480
|
+
validate_insert(at, count, column_count)
|
|
481
|
+
return self if count.zero?
|
|
482
|
+
rows = []
|
|
483
|
+
@rows.each do |item|
|
|
484
|
+
if item.is_a?(Row) && at < item.columns.summary.column_count
|
|
485
|
+
rows << Row.new(insert_axis(item.columns, at, count, :column))
|
|
486
|
+
else
|
|
487
|
+
rows << item
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
rows = tree(coalesce_gaps(rows, :row), RowSummary)
|
|
491
|
+
new_column_count = column_count + count
|
|
492
|
+
column_axis = @column_axis.insert(at, count)
|
|
493
|
+
index = @range_index || RangeIndex.new if new_column_count > 1
|
|
494
|
+
with_rows(rows, new_column_count, index, @row_axis, column_axis)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def delete_columns(at, count)
|
|
498
|
+
validate_delete(at, count, column_count)
|
|
499
|
+
return self if count.zero? || at == column_count
|
|
500
|
+
rows = []
|
|
501
|
+
index = @range_index
|
|
502
|
+
delete_count = [count, column_count - at].min
|
|
503
|
+
row = 0
|
|
504
|
+
@rows.each do |item|
|
|
505
|
+
if item.is_a?(Gap)
|
|
506
|
+
rows << item
|
|
507
|
+
row += item.length
|
|
508
|
+
next
|
|
509
|
+
end
|
|
510
|
+
if index && column_count - delete_count > 1
|
|
511
|
+
each_column(item.columns, at, at + delete_count - 1) do |column, value|
|
|
512
|
+
index = index.update(@row_axis.index_at(row), @column_axis.index_at(column), value, nil)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
if item.is_a?(Row) && at < item.columns.summary.column_count
|
|
516
|
+
columns = delete_axis(item.columns, at, [count, item.columns.summary.column_count - at].min, :column)
|
|
517
|
+
rows << (columns.summary.values.count.zero? ? Gap.new(1, axis: :row) : Row.new(columns))
|
|
518
|
+
else
|
|
519
|
+
rows << item
|
|
520
|
+
end
|
|
521
|
+
row += 1
|
|
522
|
+
end
|
|
523
|
+
rows = tree(coalesce_gaps(rows, :row), RowSummary)
|
|
524
|
+
new_column_count = column_count - delete_count
|
|
525
|
+
column_axis = @column_axis.delete(at, count)
|
|
526
|
+
index = nil if new_column_count <= 1
|
|
527
|
+
index ||= RangeIndex.new if new_column_count > 1
|
|
528
|
+
with_rows(rows, new_column_count, index, @row_axis, column_axis)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def snapshot = self
|
|
532
|
+
|
|
533
|
+
def check_invariants!
|
|
534
|
+
@rows.check_invariants!
|
|
535
|
+
@row_axis.check_invariants!
|
|
536
|
+
@column_axis.check_invariants!
|
|
537
|
+
raise "incorrect row-axis extent" unless @row_axis.extent == row_count
|
|
538
|
+
raise "incorrect column-axis extent" unless @column_axis.extent == column_count
|
|
539
|
+
if column_count > 1
|
|
540
|
+
raise "missing 2D range index" unless @range_index
|
|
541
|
+
@range_index.check_invariants!
|
|
542
|
+
range_summary = indexed_summary(0, 0, row_count - 1, column_count - 1)
|
|
543
|
+
raise "incorrect 2D range-index summary" unless range_summary == @rows.summary.values
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
actual_rows = 0
|
|
547
|
+
actual_cells = 0
|
|
548
|
+
previous_gap = false
|
|
549
|
+
@rows.each do |item|
|
|
550
|
+
raise "adjacent row gaps" if previous_gap && item.is_a?(Gap)
|
|
551
|
+
if item.is_a?(Gap)
|
|
552
|
+
actual_rows += item.length
|
|
553
|
+
previous_gap = true
|
|
554
|
+
else
|
|
555
|
+
raise "invalid row item" unless item.is_a?(Row)
|
|
556
|
+
item.columns.check_invariants!
|
|
557
|
+
actual_rows += 1
|
|
558
|
+
actual_cells += item.columns.summary.values.count
|
|
559
|
+
previous_gap = false
|
|
560
|
+
previous_column_gap = false
|
|
561
|
+
item.columns.each do |cell|
|
|
562
|
+
raise "adjacent column gaps" if previous_column_gap && cell.is_a?(Gap)
|
|
563
|
+
raise "invalid column item" unless cell.is_a?(Gap) || cell.is_a?(Cell)
|
|
564
|
+
previous_column_gap = cell.is_a?(Gap)
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
raise "incorrect row count" unless actual_rows == row_count
|
|
569
|
+
raise "incorrect cell count" unless actual_cells == cell_count
|
|
570
|
+
raise "invalid column count" unless column_count >= 0
|
|
571
|
+
true
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
private
|
|
575
|
+
|
|
576
|
+
def validate_coordinate(row, column)
|
|
577
|
+
raise ArgumentError, "row and column must be non-negative integers" unless row.is_a?(Integer) && row >= 0 && column.is_a?(Integer) && column >= 0
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def validate_range(top, left, bottom, right)
|
|
581
|
+
validate_coordinate(top, left)
|
|
582
|
+
validate_coordinate(bottom, right)
|
|
583
|
+
raise ArgumentError, "range end precedes range start" if bottom < top || right < left
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def validate_insert(at, count, extent)
|
|
587
|
+
raise ArgumentError, "insert position must be within the sheet" unless at.is_a?(Integer) && at.between?(0, extent)
|
|
588
|
+
raise ArgumentError, "count must be a non-negative integer" unless count.is_a?(Integer) && count >= 0
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def validate_delete(at, count, extent)
|
|
592
|
+
raise ArgumentError, "delete position must be within the sheet" unless at.is_a?(Integer) && at.between?(0, extent)
|
|
593
|
+
raise ArgumentError, "count must be a non-negative integer" unless count.is_a?(Integer) && count >= 0
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def empty_columns = Tree.new(summary: ColumnSummary, branching: @branching)
|
|
597
|
+
def empty_tree(summary) = Tree.new(summary: summary, branching: @branching)
|
|
598
|
+
def tree(items, summary) = Tree.new(items, summary: summary, branching: @branching)
|
|
599
|
+
def with_rows(rows, columns, range_index = nil, row_axis = @row_axis, column_axis = @column_axis)
|
|
600
|
+
self.class.__send__(:from_rows, rows, columns, @branching, range_index, row_axis, column_axis)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def indexed_summary(top, left, bottom, right)
|
|
604
|
+
return Summary.zero if top > bottom || left > right
|
|
605
|
+
|
|
606
|
+
row_ranges = @row_axis.ranges(top, bottom)
|
|
607
|
+
column_ranges = @column_axis.ranges(left, right)
|
|
608
|
+
row_ranges.sum(Summary.zero) do |row_first, row_last|
|
|
609
|
+
column_ranges.sum(Summary.zero) do |column_first, column_last|
|
|
610
|
+
@range_index.summary(row_first, column_first, row_last, column_last)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def split_axis(source, position, axis)
|
|
616
|
+
total = axis == :row ? source.summary.row_count : source.summary.column_count
|
|
617
|
+
raise RangeError, "axis position out of bounds" unless position.is_a?(Integer) && position.between?(0, total)
|
|
618
|
+
summary_type = axis == :row ? RowSummary : ColumnSummary
|
|
619
|
+
return [source, empty_tree(summary_type)] if position == total
|
|
620
|
+
index, item, prefix = source.locate(position, axis == :row ? :row_count : :column_count)
|
|
621
|
+
local = position - (axis == :row ? prefix.row_count : prefix.column_count)
|
|
622
|
+
return source.split_at(index) if local.zero?
|
|
623
|
+
before, after = source.split_at(index)
|
|
624
|
+
left_length = item.length
|
|
625
|
+
left = before.push(Gap.new(local, axis: axis))
|
|
626
|
+
right = tree([Gap.new(left_length - local, axis: axis)], summary_type).append(after.slice(1, after.size - 1))
|
|
627
|
+
[left, right]
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def concat(left, right)
|
|
631
|
+
return right if left.empty?
|
|
632
|
+
return left if right.empty?
|
|
633
|
+
last = left[left.size - 1]
|
|
634
|
+
first = right[0]
|
|
635
|
+
return left.append(right) unless last.is_a?(Gap) && first.is_a?(Gap)
|
|
636
|
+
left = left.replace_at(left.size - 1, [Gap.new(last.length + first.length, axis: last_axis(last))])
|
|
637
|
+
left.append(right.slice(1, right.size - 1))
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def last_axis(gap)
|
|
641
|
+
gap.summary.is_a?(RowSummary) ? :row : :column
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def insert_axis(source, at, count, axis)
|
|
645
|
+
before, after = split_axis(source, at, axis)
|
|
646
|
+
summary_type = axis == :row ? RowSummary : ColumnSummary
|
|
647
|
+
concat(concat(before, tree([Gap.new(count, axis: axis)], summary_type)), after)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def delete_axis(source, at, count, axis)
|
|
651
|
+
before, tail = split_axis(source, at, axis)
|
|
652
|
+
_, after = split_axis(tail, [count, (axis == :row ? tail.summary.row_count : tail.summary.column_count)].min, axis)
|
|
653
|
+
concat(before, after)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def set_column(columns, column, value)
|
|
657
|
+
columns = insert_axis(columns, columns.summary.column_count, column + 1 - columns.summary.column_count, :column) if column >= columns.summary.column_count
|
|
658
|
+
before, tail = split_axis(columns, column, :column)
|
|
659
|
+
_current, after = split_axis(tail, 1, :column)
|
|
660
|
+
concat(concat(before, tree([Cell.new(value)], ColumnSummary)), after)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def delete_column(columns, column)
|
|
664
|
+
return columns if column >= columns.summary.column_count
|
|
665
|
+
before, tail = split_axis(columns, column, :column)
|
|
666
|
+
current, after = split_axis(tail, 1, :column)
|
|
667
|
+
return columns unless current[0].is_a?(Cell)
|
|
668
|
+
concat(concat(before, tree([Gap.new(1, axis: :column)], ColumnSummary)), after)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def column_at(columns, column)
|
|
672
|
+
return nil if column >= columns.summary.column_count
|
|
673
|
+
_, item, prefix = columns.locate(column, :column_count)
|
|
674
|
+
item.is_a?(Cell) && column == prefix.column_count ? item.value : nil
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def updated_row(row, edits)
|
|
678
|
+
columns = row ? row.columns : empty_columns
|
|
679
|
+
updated = update_columns(columns, edits)
|
|
680
|
+
return row if row && updated.equal?(columns)
|
|
681
|
+
return nil if updated.summary.values.count.zero?
|
|
682
|
+
|
|
683
|
+
Row.new(updated)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def update_columns(columns, edits)
|
|
687
|
+
return columns if edits.empty?
|
|
688
|
+
|
|
689
|
+
extent = [columns.summary.column_count, *edits.filter_map { |column, value| column + 1 unless value.nil? }].max
|
|
690
|
+
result = []
|
|
691
|
+
changed = false
|
|
692
|
+
position = 0
|
|
693
|
+
edit_index = 0
|
|
694
|
+
|
|
695
|
+
columns.each do |item|
|
|
696
|
+
if item.is_a?(Gap)
|
|
697
|
+
finish = position + item.length
|
|
698
|
+
while edit_index < edits.length && edits[edit_index][0] < finish
|
|
699
|
+
column, value = edits[edit_index]
|
|
700
|
+
if value.nil?
|
|
701
|
+
append_gap(result, column - position + 1, :column) if column >= position
|
|
702
|
+
position = column + 1
|
|
703
|
+
else
|
|
704
|
+
append_gap(result, column - position, :column) if column > position
|
|
705
|
+
result << Cell.new(value)
|
|
706
|
+
position = column + 1
|
|
707
|
+
changed = true
|
|
708
|
+
end
|
|
709
|
+
edit_index += 1
|
|
710
|
+
end
|
|
711
|
+
append_gap(result, finish - position, :column) if finish > position
|
|
712
|
+
position = finish
|
|
713
|
+
else
|
|
714
|
+
if edit_index < edits.length && edits[edit_index][0] == position
|
|
715
|
+
value = edits[edit_index][1]
|
|
716
|
+
if value.nil?
|
|
717
|
+
append_gap(result, 1, :column)
|
|
718
|
+
changed = true
|
|
719
|
+
elsif value == item.value
|
|
720
|
+
result << item
|
|
721
|
+
else
|
|
722
|
+
result << Cell.new(value)
|
|
723
|
+
changed = true
|
|
724
|
+
end
|
|
725
|
+
edit_index += 1
|
|
726
|
+
else
|
|
727
|
+
result << item
|
|
728
|
+
end
|
|
729
|
+
position += 1
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
while edit_index < edits.length
|
|
734
|
+
column, value = edits[edit_index]
|
|
735
|
+
if !value.nil?
|
|
736
|
+
append_gap(result, column - position, :column) if column > position
|
|
737
|
+
result << Cell.new(value)
|
|
738
|
+
position = column + 1
|
|
739
|
+
changed = true
|
|
740
|
+
end
|
|
741
|
+
edit_index += 1
|
|
742
|
+
end
|
|
743
|
+
append_gap(result, extent - position, :column) if extent > position
|
|
744
|
+
return columns unless changed
|
|
745
|
+
|
|
746
|
+
tree(result, ColumnSummary)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def append_row(items, row)
|
|
750
|
+
append_gap(items, 1) unless row
|
|
751
|
+
items << row if row
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def append_gap(items, length, axis = :row)
|
|
755
|
+
return if length.zero?
|
|
756
|
+
|
|
757
|
+
if items.last.is_a?(Gap)
|
|
758
|
+
items[-1] = Gap.new(items.last.length + length, axis: axis)
|
|
759
|
+
else
|
|
760
|
+
items << Gap.new(length, axis: axis)
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def each_column(columns, left, right)
|
|
765
|
+
return if left >= columns.summary.column_count
|
|
766
|
+
_, tail = split_axis(columns, left, :column)
|
|
767
|
+
selected, = split_axis(tail, [right + 1 - left, columns.summary.column_count - left].min, :column)
|
|
768
|
+
column = left
|
|
769
|
+
selected.each do |item|
|
|
770
|
+
if item.is_a?(Gap)
|
|
771
|
+
column += item.length
|
|
772
|
+
else
|
|
773
|
+
yield column, item.value
|
|
774
|
+
column += 1
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def column_summary(columns, left, right)
|
|
780
|
+
return Summary.zero if left >= columns.summary.column_count
|
|
781
|
+
_, tail = split_axis(columns, left, :column)
|
|
782
|
+
selected, = split_axis(tail, [right + 1 - left, columns.summary.column_count - left].min, :column)
|
|
783
|
+
selected.summary.values
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def coalesce_gaps(items, axis)
|
|
787
|
+
items.each_with_object([]) do |item, result|
|
|
788
|
+
if item.is_a?(Gap) && result.last.is_a?(Gap)
|
|
789
|
+
result[-1] = Gap.new(result.last.length + item.length, axis: axis)
|
|
790
|
+
else
|
|
791
|
+
result << item
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
class Sheet
|
|
5
|
+
# A persistent row segment tree whose nodes cache a persistent column segment
|
|
6
|
+
# tree. Point edits touch log(row extent) * log(column extent) index nodes.
|
|
7
|
+
class RangeIndex
|
|
8
|
+
class ValueNode
|
|
9
|
+
attr_reader :key, :count, :left, :right, :height, :minimum, :maximum
|
|
10
|
+
|
|
11
|
+
def initialize(key, count, left, right)
|
|
12
|
+
@key, @count, @left, @right = key, count, left, right
|
|
13
|
+
@height = 1 + [left&.height || 0, right&.height || 0].max
|
|
14
|
+
@minimum = left ? left.minimum : key
|
|
15
|
+
@maximum = right ? right.maximum : key
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module ValueTree
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def update(root, key, delta)
|
|
24
|
+
if root.nil?
|
|
25
|
+
raise "missing value in range index" unless delta.positive?
|
|
26
|
+
return ValueNode.new(key, delta, nil, nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
comparison = key <=> root.key
|
|
30
|
+
raise ArgumentError, "numeric cell values are not mutually comparable" unless comparison
|
|
31
|
+
|
|
32
|
+
if comparison.zero?
|
|
33
|
+
count = root.count + delta
|
|
34
|
+
return remove_root(root) if count.zero?
|
|
35
|
+
raise "negative value count in range index" if count.negative?
|
|
36
|
+
return ValueNode.new(root.key, count, root.left, root.right)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if comparison.negative?
|
|
40
|
+
balance(ValueNode.new(root.key, root.count, update(root.left, key, delta), root.right))
|
|
41
|
+
else
|
|
42
|
+
balance(ValueNode.new(root.key, root.count, root.left, update(root.right, key, delta)))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove_root(root)
|
|
47
|
+
return root.right unless root.left
|
|
48
|
+
return root.left unless root.right
|
|
49
|
+
|
|
50
|
+
successor = root.right
|
|
51
|
+
successor = successor.left while successor.left
|
|
52
|
+
right = update(root.right, successor.key, -successor.count)
|
|
53
|
+
balance(ValueNode.new(successor.key, successor.count, root.left, right))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def balance(root)
|
|
57
|
+
factor = height(root.left) - height(root.right)
|
|
58
|
+
if factor > 1
|
|
59
|
+
left = root.left
|
|
60
|
+
left = rotate_left(left) if height(left.left) < height(left.right)
|
|
61
|
+
return rotate_right(ValueNode.new(root.key, root.count, left, root.right))
|
|
62
|
+
elsif factor < -1
|
|
63
|
+
right = root.right
|
|
64
|
+
right = rotate_right(right) if height(right.right) < height(right.left)
|
|
65
|
+
return rotate_left(ValueNode.new(root.key, root.count, root.left, right))
|
|
66
|
+
end
|
|
67
|
+
root
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rotate_left(root)
|
|
71
|
+
pivot = root.right
|
|
72
|
+
left = ValueNode.new(root.key, root.count, root.left, pivot.left)
|
|
73
|
+
ValueNode.new(pivot.key, pivot.count, left, pivot.right)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def rotate_right(root)
|
|
77
|
+
pivot = root.left
|
|
78
|
+
right = ValueNode.new(root.key, root.count, pivot.right, root.right)
|
|
79
|
+
ValueNode.new(pivot.key, pivot.count, pivot.left, right)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def height(node) = node&.height || 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class ValueBag
|
|
86
|
+
attr_reader :summary, :count
|
|
87
|
+
|
|
88
|
+
def initialize(count: 0, sum: 0, types: {}, numbers: nil)
|
|
89
|
+
@count, @sum, @types, @numbers = count, sum, types.freeze, numbers
|
|
90
|
+
@summary = Sheet::Summary.new(count: count, sum: sum,
|
|
91
|
+
min: numbers&.minimum, max: numbers&.maximum, types: @types)
|
|
92
|
+
freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
ZERO = new
|
|
96
|
+
|
|
97
|
+
def self.zero = ZERO
|
|
98
|
+
|
|
99
|
+
def replace(old_value, new_value)
|
|
100
|
+
bag = old_value.nil? ? self : update(old_value, -1)
|
|
101
|
+
new_value.nil? ? bag : bag.update(new_value, 1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def update(value, delta)
|
|
105
|
+
return self if delta.zero?
|
|
106
|
+
|
|
107
|
+
types = @types.dup
|
|
108
|
+
type_count = types.fetch(value.class, 0) + delta
|
|
109
|
+
raise "negative type count in range index" if type_count.negative?
|
|
110
|
+
type_count.zero? ? types.delete(value.class) : types[value.class] = type_count
|
|
111
|
+
sum = @sum + (value.is_a?(Numeric) ? value * delta : 0)
|
|
112
|
+
numbers = if comparable_number?(value)
|
|
113
|
+
ValueTree.update(@numbers, value, delta)
|
|
114
|
+
else
|
|
115
|
+
@numbers
|
|
116
|
+
end
|
|
117
|
+
self.class.new(count: @count + delta, sum: sum, types: types, numbers: numbers)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def empty? = count.zero?
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def comparable_number?(value)
|
|
125
|
+
value.is_a?(Numeric) && !(value <=> value).nil?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class ColumnNode
|
|
130
|
+
attr_reader :left, :right, :bag, :summary
|
|
131
|
+
|
|
132
|
+
def initialize(left: nil, right: nil, bag: nil)
|
|
133
|
+
@left, @right, @bag = left, right, bag
|
|
134
|
+
@summary = bag ? bag.summary : summary_of(left) + summary_of(right)
|
|
135
|
+
freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def summary_of(node) = node&.summary || Sheet::Summary.zero
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class ColumnIndex
|
|
144
|
+
attr_reader :root, :capacity
|
|
145
|
+
|
|
146
|
+
def initialize(root: nil, capacity: 1)
|
|
147
|
+
@root, @capacity = root, capacity
|
|
148
|
+
freeze
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def update(column, old_value, new_value)
|
|
152
|
+
return self if old_value == new_value
|
|
153
|
+
|
|
154
|
+
root, capacity = @root, @capacity
|
|
155
|
+
while column >= capacity
|
|
156
|
+
root = ColumnNode.new(left: root, right: nil) if root
|
|
157
|
+
capacity *= 2
|
|
158
|
+
end
|
|
159
|
+
root = update_node(root, 0, capacity, column, old_value, new_value)
|
|
160
|
+
self.class.new(root: root, capacity: capacity)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def summary(left, right)
|
|
164
|
+
return Sheet::Summary.zero unless @root && left < @capacity
|
|
165
|
+
|
|
166
|
+
query(@root, 0, @capacity, left, [right + 1, @capacity].min)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def empty? = @root.nil?
|
|
170
|
+
|
|
171
|
+
def check_invariants!
|
|
172
|
+
check_node(@root, 0, @capacity) if @root
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def update_node(node, first, finish, column, old_value, new_value)
|
|
179
|
+
if finish - first == 1
|
|
180
|
+
bag = (node&.bag || ValueBag.zero).replace(old_value, new_value)
|
|
181
|
+
return nil if bag.empty?
|
|
182
|
+
|
|
183
|
+
return ColumnNode.new(bag: bag)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
middle = (first + finish) / 2
|
|
187
|
+
left, right = node&.left, node&.right
|
|
188
|
+
if column < middle
|
|
189
|
+
left = update_node(left, first, middle, column, old_value, new_value)
|
|
190
|
+
else
|
|
191
|
+
right = update_node(right, middle, finish, column, old_value, new_value)
|
|
192
|
+
end
|
|
193
|
+
return nil unless left || right
|
|
194
|
+
|
|
195
|
+
ColumnNode.new(left: left, right: right)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def query(node, first, finish, left, right)
|
|
199
|
+
return Sheet::Summary.zero unless node && left < finish && first < right
|
|
200
|
+
return node.summary if left <= first && finish <= right
|
|
201
|
+
|
|
202
|
+
middle = (first + finish) / 2
|
|
203
|
+
query(node.left, first, middle, left, right) + query(node.right, middle, finish, left, right)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def check_node(node, first, finish)
|
|
207
|
+
if finish - first == 1
|
|
208
|
+
raise "invalid column-index leaf" unless node.bag && !node.left && !node.right && node.summary == node.bag.summary
|
|
209
|
+
return node.summary
|
|
210
|
+
end
|
|
211
|
+
raise "invalid column-index branch" if node.bag
|
|
212
|
+
middle = (first + finish) / 2
|
|
213
|
+
summary = (node.left ? check_node(node.left, first, middle) : Sheet::Summary.zero) +
|
|
214
|
+
(node.right ? check_node(node.right, middle, finish) : Sheet::Summary.zero)
|
|
215
|
+
raise "incorrect column-index summary" unless node.summary == summary
|
|
216
|
+
summary
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class RowNode
|
|
221
|
+
attr_reader :left, :right, :columns
|
|
222
|
+
|
|
223
|
+
def initialize(left, right, columns)
|
|
224
|
+
@left, @right, @columns = left, right, columns
|
|
225
|
+
freeze
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
attr_reader :root, :capacity
|
|
230
|
+
|
|
231
|
+
def initialize(root: nil, capacity: 1)
|
|
232
|
+
@root, @capacity = root, capacity
|
|
233
|
+
freeze
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def update(row, column, old_value, new_value)
|
|
237
|
+
return self if unchanged_value?(old_value, new_value)
|
|
238
|
+
|
|
239
|
+
root, capacity = @root, @capacity
|
|
240
|
+
while row >= capacity
|
|
241
|
+
root = RowNode.new(root, nil, root.columns) if root
|
|
242
|
+
capacity *= 2
|
|
243
|
+
end
|
|
244
|
+
root = update_row(root, 0, capacity, row, column, old_value, new_value)
|
|
245
|
+
self.class.new(root: root, capacity: capacity)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def summary(top, left, bottom, right)
|
|
249
|
+
return Sheet::Summary.zero unless @root && top < @capacity
|
|
250
|
+
|
|
251
|
+
query(@root, 0, @capacity, top, [bottom + 1, @capacity].min, left, right)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def self.from_rows(rows, row_axis, column_axis)
|
|
255
|
+
index = new
|
|
256
|
+
row = 0
|
|
257
|
+
rows.each do |item|
|
|
258
|
+
if item.is_a?(Gap)
|
|
259
|
+
row += item.length
|
|
260
|
+
next
|
|
261
|
+
end
|
|
262
|
+
column = 0
|
|
263
|
+
item.columns.each do |cell|
|
|
264
|
+
if cell.is_a?(Gap)
|
|
265
|
+
column += cell.length
|
|
266
|
+
else
|
|
267
|
+
index = index.update(row_axis.index_at(row), column_axis.index_at(column), nil, cell.value)
|
|
268
|
+
column += 1
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
row += 1
|
|
272
|
+
end
|
|
273
|
+
index
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def check_invariants!
|
|
277
|
+
check_row(@root, 0, @capacity) if @root
|
|
278
|
+
true
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def unchanged_value?(old_value, new_value)
|
|
284
|
+
old_value.nil? ? new_value.nil? : !new_value.nil? && old_value.class == new_value.class && old_value == new_value
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def update_row(node, first, finish, row, column, old_value, new_value)
|
|
288
|
+
columns = (node&.columns || ColumnIndex.new).update(column, old_value, new_value)
|
|
289
|
+
if finish - first == 1
|
|
290
|
+
return nil if columns.empty?
|
|
291
|
+
return RowNode.new(nil, nil, columns)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
middle = (first + finish) / 2
|
|
295
|
+
left, right = node&.left, node&.right
|
|
296
|
+
if row < middle
|
|
297
|
+
left = update_row(left, first, middle, row, column, old_value, new_value)
|
|
298
|
+
else
|
|
299
|
+
right = update_row(right, middle, finish, row, column, old_value, new_value)
|
|
300
|
+
end
|
|
301
|
+
return nil if columns.empty?
|
|
302
|
+
|
|
303
|
+
RowNode.new(left, right, columns)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def query(node, first, finish, top, bottom, left, right)
|
|
307
|
+
return Sheet::Summary.zero unless node && top < finish && first < bottom
|
|
308
|
+
return node.columns.summary(left, right) if top <= first && finish <= bottom
|
|
309
|
+
|
|
310
|
+
middle = (first + finish) / 2
|
|
311
|
+
query(node.left, first, middle, top, bottom, left, right) +
|
|
312
|
+
query(node.right, middle, finish, top, bottom, left, right)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def check_row(node, first, finish)
|
|
316
|
+
node.columns.check_invariants!
|
|
317
|
+
return if finish - first == 1
|
|
318
|
+
|
|
319
|
+
middle = (first + finish) / 2
|
|
320
|
+
check_row(node.left, first, middle) if node.left
|
|
321
|
+
check_row(node.right, middle, finish) if node.right
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private_constant :RangeIndex
|
|
326
|
+
end
|
|
327
|
+
end
|
data/lib/denebola/version.rb
CHANGED
data/lib/denebola.rb
CHANGED
data/sig/denebola.rbs
CHANGED
|
@@ -192,4 +192,34 @@ module Denebola
|
|
|
192
192
|
def initialize: (Integer, ?bias: :left | :right) -> void
|
|
193
193
|
def transform: (Array[[Range[Integer], String]]) -> Anchor
|
|
194
194
|
end
|
|
195
|
+
|
|
196
|
+
class Sheet
|
|
197
|
+
DEFAULT_BRANCHING: Integer
|
|
198
|
+
attr_reader row_count: Integer
|
|
199
|
+
attr_reader column_count: Integer
|
|
200
|
+
attr_reader cell_count: Integer
|
|
201
|
+
def initialize: (?branching: Integer) -> void
|
|
202
|
+
def []: (Integer, Integer) -> untyped
|
|
203
|
+
def set: (Integer, Integer, untyped) -> Sheet
|
|
204
|
+
def set_many: (Enumerable[[Integer, Integer, untyped]]) -> Sheet
|
|
205
|
+
def delete: (Integer, Integer) -> Sheet
|
|
206
|
+
def each_in: (Integer, Integer, Integer, Integer) { (Point, untyped) -> void } -> self
|
|
207
|
+
| (Integer, Integer, Integer, Integer) -> Enumerator[[Point, untyped], self]
|
|
208
|
+
def summary: (Integer, Integer, Integer, Integer) -> Summary
|
|
209
|
+
def insert_rows: (Integer, Integer) -> Sheet
|
|
210
|
+
def delete_rows: (Integer, Integer) -> Sheet
|
|
211
|
+
def insert_columns: (Integer, Integer) -> Sheet
|
|
212
|
+
def delete_columns: (Integer, Integer) -> Sheet
|
|
213
|
+
def snapshot: () -> Sheet
|
|
214
|
+
def check_invariants!: () -> true
|
|
215
|
+
|
|
216
|
+
class Summary
|
|
217
|
+
attr_reader count: Integer
|
|
218
|
+
attr_reader sum: Numeric
|
|
219
|
+
attr_reader min: Numeric?
|
|
220
|
+
attr_reader max: Numeric?
|
|
221
|
+
attr_reader types: Hash[Class, Integer]
|
|
222
|
+
def self.zero: () -> Summary
|
|
223
|
+
end
|
|
224
|
+
end
|
|
195
225
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: denebola
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Immutable text ropes plus bounded-memory, file-backed editing with line
|
|
14
14
|
and UTF-16 indexing.
|
|
@@ -30,6 +30,8 @@ files:
|
|
|
30
30
|
- lib/denebola/lazy_rope.rb
|
|
31
31
|
- lib/denebola/point.rb
|
|
32
32
|
- lib/denebola/rope.rb
|
|
33
|
+
- lib/denebola/sheet.rb
|
|
34
|
+
- lib/denebola/sheet_range_index.rb
|
|
33
35
|
- lib/denebola/text_summary.rb
|
|
34
36
|
- lib/denebola/tree.rb
|
|
35
37
|
- lib/denebola/version.rb
|