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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f055c7610d7665e51b0f089b0d408584ed42eef42cc4511c9a18d46887618366
4
- data.tar.gz: ced4c1c876d1e11e55089c61ce07a13679d0c53ec61a1734a8d85cb77447fd48
3
+ metadata.gz: 2039904dc6d7a0fcb5fed4aa2654588cc99bde0451961cf034ded4cee04c1d13
4
+ data.tar.gz: 6ea776a47897832290d5900414b7a6e663dbbc3f2310a6c513e68c236dd632d8
5
5
  SHA512:
6
- metadata.gz: 83906a37466f697707ec32ef73b4ef3baf60a33ac95b757a18b463139593a72b6d16fa68d033f6f5caba82720f170f192344f25f78836ac298d4b750772a277e
7
- data.tar.gz: 923e8951c8cd9621b2c194be56a40b8d5fa548ecf7bd5cef17c0b4f2449b7e4ffeeee2cda26baef2f313ad0af79a8e37905bb97b668b5a550f426bb5e4a467c8
6
+ metadata.gz: 932e37da79bfebdea36da0a517828d7d72e09378a7d704852caaef926eaa11b525d14029166bdbec3caedd1b8663199855495751e9e2ee27623c3403eed4a68e
7
+ data.tar.gz: 3677c6e1c45095b1eca7a8ecf250c55e271e0ad6379310c08ec63e352084a0da572c6ba1979f477b29d9a557edccbe46b62cdc51f8c196b2bc41ae8ac08b3ad9
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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 sequences. It provides a generic summary B+ tree and a Unicode-aware text rope built on it. Every edit returns a new value while reusing untouched subtrees, so retaining a snapshot is an ordinary assignment.
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