denebola 0.2.2 → 0.4.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 +8 -1
- data/README.md +57 -1
- data/lib/denebola/code_editor_buffer.rb +52 -0
- 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 +45 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2039904dc6d7a0fcb5fed4aa2654588cc99bde0451961cf034ded4cee04c1d13
|
|
4
|
+
data.tar.gz: 6ea776a47897832290d5900414b7a6e663dbbc3f2310a6c513e68c236dd632d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 932e37da79bfebdea36da0a517828d7d72e09378a7d704852caaef926eaa11b525d14029166bdbec3caedd1b8663199855495751e9e2ee27623c3403eed4a68e
|
|
7
|
+
data.tar.gz: 3677c6e1c45095b1eca7a8ecf250c55e271e0ad6379310c08ec63e352084a0da572c6ba1979f477b29d9a557edccbe46b62cdc51f8c196b2bc41ae8ac08b3ad9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.4.0 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- Add a rope-backed buffer adapter for Zaniah CodeEditor.
|
|
6
|
+
|
|
7
|
+
## 0.3.0 — 2026-09-23
|
|
8
|
+
|
|
9
|
+
- Add persistent sparse two-dimensional sheets with range summaries.
|
|
10
|
+
- Add efficient batch writes for importing sparse or dense cell ranges.
|
|
4
11
|
|
|
5
12
|
## 0.2.2 — 2026-09-18
|
|
6
13
|
|
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
|
|
@@ -131,6 +133,25 @@ rope.offset_at_utf16_point(Denebola::Point.new(0, 2)) # => 4
|
|
|
131
133
|
|
|
132
134
|
`line(row)` omits the terminator. Rows are zero-based; empty text and a final empty row after a terminator each count as a line. A byte position between CR and LF normalizes to the following row, column zero; converting that point back returns the position after LF.
|
|
133
135
|
|
|
136
|
+
### Zaniah CodeEditor buffer
|
|
137
|
+
|
|
138
|
+
The optional adapter exposes a persistent `Rope` through Zaniah's line-addressed
|
|
139
|
+
CodeEditor buffer interface. Denebola does not depend on Zaniah:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
require "denebola/code_editor_buffer"
|
|
143
|
+
require "zaniah/ui"
|
|
144
|
+
|
|
145
|
+
buffer = Denebola::CodeEditorBuffer.new(Denebola::Rope.new("hello\nworld"))
|
|
146
|
+
editor = Zaniah::UI::CodeEditor.new(buffer: buffer)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`line_count`, `line`, `line_start`, and `line_of` use Rope indexes; offsets are
|
|
150
|
+
UTF-8 byte positions. `replace`, `undo`, and `redo` keep structurally shared
|
|
151
|
+
snapshots, and `can_undo?`/`can_redo?` control editor actions. `to_s` materializes
|
|
152
|
+
the full document when CodeEditor requests its value. `LazyRope` is not accepted:
|
|
153
|
+
its default line count is an estimate, whereas CodeEditor needs an exact count.
|
|
154
|
+
|
|
134
155
|
`rope.summary` exposes `bytesize`, `length`, `utf16_length`, `break_count`, `longest_row` (the 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.
|
|
135
156
|
|
|
136
157
|
### Anchors
|
|
@@ -150,6 +171,33 @@ anchor.offset # => 5
|
|
|
150
171
|
|
|
151
172
|
`: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
173
|
|
|
174
|
+
## Sparse Sheet
|
|
175
|
+
|
|
176
|
+
`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).
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
sheet = Denebola::Sheet.new
|
|
180
|
+
sheet = sheet.set(0, 0, 12).set(4, 2, 30)
|
|
181
|
+
previous = sheet.snapshot
|
|
182
|
+
|
|
183
|
+
sheet[4, 2] # => 30
|
|
184
|
+
sheet.summary(0, 0, 4, 2).sum # => 42
|
|
185
|
+
sheet.each_in(0, 0, 4, 2).to_a # => [[Denebola::Point.new(0, 0), 12], [Denebola::Point.new(4, 2), 30]]
|
|
186
|
+
sheet = sheet.insert_rows(2, 1).delete(0, 0)
|
|
187
|
+
previous[0, 0] # => 12
|
|
188
|
+
|
|
189
|
+
sheet = Denebola::Sheet.new.set_many([[0, 0, 12], [0, 1, 8], [1_000_000, 3, "far"]])
|
|
190
|
+
sheet[1_000_000, 3] # => "far"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
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`.
|
|
194
|
+
|
|
195
|
+
`each_in` yields `(Denebola::Point, value)`, so it can directly back a cell-source callback that accepts `(reference, value)`.
|
|
196
|
+
|
|
197
|
+
`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.
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
153
201
|
## Generic Summary Tree
|
|
154
202
|
|
|
155
203
|
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 +237,14 @@ Text dimensions are available as `Denebola::Dimensions::BYTES`, `CHARACTERS`, `U
|
|
|
189
237
|
bundle exec rake bench
|
|
190
238
|
```
|
|
191
239
|
|
|
240
|
+
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:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
bundle exec ruby -Ilib bench/sheet_range_index.rb
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
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.
|
|
247
|
+
|
|
192
248
|
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
249
|
|
|
194
250
|
Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../denebola"
|
|
4
|
+
|
|
5
|
+
module Denebola
|
|
6
|
+
# Adapts a persistent Rope to Zaniah::UI::CodeEditor's buffer interface.
|
|
7
|
+
# Zaniah is not required by Denebola; pass an instance as `buffer:`.
|
|
8
|
+
class CodeEditorBuffer
|
|
9
|
+
attr_reader :rope
|
|
10
|
+
|
|
11
|
+
def initialize(rope = Rope.new)
|
|
12
|
+
raise ArgumentError, "expected a Denebola::Rope" unless rope.is_a?(Rope)
|
|
13
|
+
@rope = rope
|
|
14
|
+
@undo = []
|
|
15
|
+
@redo = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def line_count = @rope.line_count
|
|
19
|
+
def line(index) = @rope.line(index)
|
|
20
|
+
def line_start(index) = @rope.line_start(index)
|
|
21
|
+
def line_of(offset)
|
|
22
|
+
row = @rope.point_at(offset).row
|
|
23
|
+
row.positive? && @rope.line_start(row) > offset ? row - 1 : row
|
|
24
|
+
end
|
|
25
|
+
def to_s = @rope.to_s
|
|
26
|
+
|
|
27
|
+
def replace(range, text)
|
|
28
|
+
updated = @rope.replace(range, text)
|
|
29
|
+
@undo << @rope
|
|
30
|
+
@redo.clear
|
|
31
|
+
@rope = updated
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def undo
|
|
36
|
+
return self unless can_undo?
|
|
37
|
+
@redo << @rope
|
|
38
|
+
@rope = @undo.pop
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def redo
|
|
43
|
+
return self unless can_redo?
|
|
44
|
+
@undo << @rope
|
|
45
|
+
@rope = @redo.pop
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def can_undo? = !@undo.empty?
|
|
50
|
+
def can_redo? = !@redo.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|