denebola 0.1.0 → 0.2.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: ddb3a396701e9479f38cc53d23f86e568e7d2e31b4a38576d6670dc8f5026c56
4
- data.tar.gz: 718554282d9670f3c656e06b67f9219679be4ec626cad2c390f49d8ff720cd27
3
+ metadata.gz: 605dec1949038f3bf81ca5b49d875231c515fdc656933467f2d34d1b1411da7e
4
+ data.tar.gz: 856f7ba61ad06df9f280850a4912259d1a76556cf32e0e5e8b8c78e6cec264e5
5
5
  SHA512:
6
- metadata.gz: c4af8ed526cacc5ed81c91614349b79d985ee8851d7c5ee47e58cc7bea011aa6ce1f461c390a1ec5fdc5d731ae16a9ce5ad24e4291d8fe34c4e3926341e8f3a9
7
- data.tar.gz: 4728022bcf1941df19c6cbb808ad8ece063582402d41d44f7a24be21924701e8b081edaef15999538c02c95de990327a297511b11a7aa184b81b0bfda3e3c609
6
+ metadata.gz: 02af176392f474c79428f0837f7ea44ed7198a25a56a250d6119b80ddc9a9bf6f5e5eba7e020417c3c3b922ec65912b075050e6224993b73e78f4da1c8cf1d79
7
+ data.tar.gz: 2d69fa29d55fb1970b30da016df0cc4730985ec286aaaa2048c08f17472f38e3904d37cefaa75961b6f3e476e1e86e3bd45fa1223dbe7101a2802dd480a2e8ed
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-09-15
4
+
5
+ - Add `LazyRope` for bounded-memory access to very large UTF-8 files.
6
+ - Build line, character, and UTF-16 indexes incrementally.
7
+ - Keep edits as in-memory overlays while untouched bytes remain file-backed.
8
+ - Detect files changed or replaced after opening.
9
+
3
10
  ## 0.1.0 — 2026-09-10
4
11
 
5
12
  - Initial release.
data/README.md CHANGED
@@ -1,12 +1,66 @@
1
- # Denebola
1
+ <h1 align="center">Denebola</h1>
2
2
 
3
- Persistent summary B+ trees and Unicode text ropes, in pure Ruby. Ruby 3.1 or later; no runtime dependencies.
3
+ <p align="center">
4
+ <strong>Persistent summary B+ trees and Unicode text ropes in pure Ruby</strong>
5
+ </p>
4
6
 
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.
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/denebola"><img src="https://img.shields.io/gem/v/denebola.svg?colorB=319e8c" alt="Gem version"></a>
9
+ <a href="https://rubygems.org/gems/denebola"><img src="https://img.shields.io/gem/dt/denebola.svg" alt="Downloads"></a>
10
+ <a href="https://github.com/noxdea/denebola/actions/workflows/main.yml"><img src="https://github.com/noxdea/denebola/actions/workflows/main.yml/badge.svg" alt="CI"></a>
11
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby version">
12
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
13
+ </p>
6
14
 
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.
15
+ <p align="center">
16
+ <a href="#features">Features</a> ·
17
+ <a href="#installation">Installation</a> ·
18
+ <a href="#quick-start">Quick Start</a> ·
19
+ <a href="#text-rope">Text Rope</a> ·
20
+ <a href="#generic-summary-tree">Summary Tree</a> ·
21
+ <a href="#benchmarks">Benchmarks</a>
22
+ </p>
8
23
 
9
- ## Text editing
24
+ ---
25
+
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
+
28
+ ## Features
29
+
30
+ - Persistent text editing with structural sharing
31
+ - Bounded-memory, file-backed editing for multi-gigabyte text
32
+ - UTF-8 byte, Unicode codepoint, UTF-16, and line-based indexing
33
+ - Batched edits and explicit anchor transformation
34
+ - LF, CRLF, CR, U+2028, and U+2029 line break support
35
+ - Generic summary B+ tree with dimension-based cursors
36
+ - Pure Ruby 3.1+ with no runtime dependencies
37
+ - RBS signatures for the public API
38
+
39
+ ## Installation
40
+
41
+ Add Denebola to your Gemfile:
42
+
43
+ ```ruby
44
+ gem "denebola"
45
+ ```
46
+
47
+ Then install:
48
+
49
+ ```bash
50
+ bundle install
51
+ ```
52
+
53
+ Or install it directly:
54
+
55
+ ```bash
56
+ gem install denebola
57
+ ```
58
+
59
+ ### Requirements
60
+
61
+ - Ruby 3.1 or later
62
+
63
+ ## Quick Start
10
64
 
11
65
  ```ruby
12
66
  require "denebola"
@@ -14,32 +68,56 @@ require "denebola"
14
68
  rope = Denebola::Rope.new("hello\nworld")
15
69
  snapshot = rope
16
70
  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
71
+
72
+ rope.line(0) # => "hello, there"
73
+ rope.to_s # => "hello, there\nworld"
74
+ snapshot.to_s # => "hello\nworld"
26
75
  ```
27
76
 
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.
77
+ For files that should not be copied into memory, open a `LazyRope`:
78
+
79
+ ```ruby
80
+ text = Denebola::LazyRope.open("server.log")
81
+ text.line(1_000_000) # indexes only as far as needed
82
+ text.line_count # estimate until the index reaches EOF
83
+ text.line_count(exact: true) # force an exact count
84
+ text.edit(0...5, "INFO:") # untouched bytes remain file-backed
85
+ text.materialize(0...1024) # an ordinary editable Rope
86
+ text.close
87
+ ```
29
88
 
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.
89
+ `LazyRope` reads 1 MiB chunks by default and keeps eight chunks in an LRU cache. `byteslice`, line access, byte/codepoint positions, UTF-16 positions, and anchors follow `Rope` semantics. `edit`, `insert`, `delete`, and `replace` mutate the open view and store only replacement text in memory; call `materialize` when an immutable `Rope` snapshot is needed. A file changed, removed, or replaced after opening raises `Denebola::Error`. `to_s` and `materialize` without a range intentionally load the complete logical file.
90
+
91
+ ## Text Rope
92
+
93
+ ### Editing
31
94
 
32
95
  ```ruby
33
96
  rope = Denebola::Rope.new("hello\nworld")
34
- rope.apply_edits([[0...5, "Hi"], [6...11, "Ruby"]]).to_s # => "Hi\nRuby"
97
+
98
+ rope.bytesize # => 11
99
+ rope.length # => 11 Unicode codepoints
100
+ rope.line_count # => 2
101
+ rope.byteslice(0, 5).to_s # => "hello"
102
+ rope.delete(0...5).to_s # => "\nworld"
103
+ rope.replace(0...5, "Hi").to_s # => "Hi\nworld"
104
+ rope.each_chunk { |text| puts text } # Frozen strings, without flattening
105
+
106
+ rope.apply_edits([[0...5, "Hi"], [6...11, "Ruby"]]).to_s
107
+ # => "Hi\nRuby"
35
108
  ```
36
109
 
37
- ## Lines and positions
110
+ Ranges passed to `replace`, `apply_edits`, and `byteslice` address bytes in UTF-8. Bounds and codepoint boundaries are checked. Inclusive, exclusive, beginless, and endless Ruby ranges are accepted; invalid encodings, split codepoints, and overlapping batch edits raise exceptions.
111
+
112
+ `apply_edits` applies nonoverlapping edits against one original snapshot and sorts them into source order. Adjacent ranges are allowed. Input strings are copied or shared safely with Ruby's copy-on-write strings, so later mutation of the source cannot alter a rope.
38
113
 
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.
114
+ 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.
115
+
116
+ ### Lines and Positions
40
117
 
41
118
  ```ruby
42
119
  rope = Denebola::Rope.new("😀\n日本")
120
+
43
121
  rope.point_at(8) # => Point(row: 1, column: 1)
44
122
  rope.offset_at(Denebola::Point.new(1, 1)) # => 8
45
123
  rope.line_start(1) # => 5
@@ -49,28 +127,32 @@ rope.utf16_point_at(4) # => Point(row: 0, column: 2)
49
127
  rope.offset_at_utf16_point(Denebola::Point.new(0, 2)) # => 4
50
128
  ```
51
129
 
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.
130
+ `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. 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.
131
+
132
+ `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.
53
133
 
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.
134
+ `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.
55
135
 
56
- ## Anchors
136
+ ### Anchors
57
137
 
58
- Anchors transform explicitly using the same finite byte ranges as an edit, without retaining the entire edit history:
138
+ Anchors transform explicitly using the same finite byte ranges as an edit, without retaining the edit history:
59
139
 
60
140
  ```ruby
61
141
  rope = Denebola::Rope.new("hello")
62
142
  anchor = rope.anchor(2, bias: :right)
63
143
  edits = [[2...2, "XYZ"]]
144
+
64
145
  anchor = anchor.transform(edits)
65
146
  rope = rope.apply_edits(edits)
147
+
66
148
  anchor.offset # => 5
67
149
  ```
68
150
 
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.
151
+ `: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.
70
152
 
71
- ## Generic summary tree
153
+ ## Generic Summary Tree
72
154
 
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.
155
+ 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.
74
156
 
75
157
  ```ruby
76
158
  Weight = Struct.new(:weight) do
@@ -79,27 +161,35 @@ Weight = Struct.new(:weight) do
79
161
  def summary = self
80
162
  end
81
163
 
82
- tree = Denebola::Tree.new([Weight.new(2).freeze, Weight.new(3).freeze], summary: Weight)
164
+ tree = Denebola::Tree.new(
165
+ [Weight.new(2).freeze, Weight.new(3).freeze],
166
+ summary: Weight
167
+ )
83
168
  tree = tree.push(Weight.new(5).freeze)
84
169
  cursor = tree.cursor(:weight).seek(2)
170
+
85
171
  cursor.item.weight # => 3
86
- cursor.summary.weight # => 2: summary before the current item
172
+ cursor.summary.weight # => 2, the summary before the current item
87
173
  cursor.next.weight # => 5
88
174
  cursor.prev.weight # => 3
89
175
  cursor.seek(2, bias: :left).item.weight # => 2
90
176
  ```
91
177
 
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.
178
+ `Tree.from(items, summary: ...)` and `Tree.new(items, summary: ...)` bulk-build an 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
179
 
94
180
  `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
181
 
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.
182
+ Text dimensions are 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, while splitting and joining preserve occupancy and equal leaf depth. Returning a string or a long line necessarily costs at least its output size.
183
+
184
+ `check_invariants!` checks occupancy, frozen nodes, depth, prefix counts, and summaries. It is intended for tests.
97
185
 
98
- ## Benchmarks and validation
186
+ ## Benchmarks
99
187
 
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.
188
+ ```bash
189
+ bundle exec rake bench
190
+ ```
101
191
 
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)`.
192
+ 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)`.
103
193
 
104
194
  Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
105
195
 
@@ -113,8 +203,23 @@ Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
113
203
  | Slice 1 KB | 6.79 µs | 20 µs |
114
204
  | Retained Ruby object memory | 33.84 MiB | 40 MiB |
115
205
 
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.
206
+ 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 wider timing ceilings for shared CI hardware while retaining the 40 MiB memory ceiling.
207
+
208
+ ## Development
209
+
210
+ ```bash
211
+ bundle install
212
+ bundle exec rake test
213
+ ```
214
+
215
+ 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, and `ruby tools/check_isolation.rb` checks runtime independence.
216
+
217
+ CI tests Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 on Linux, macOS, and Windows. See the [RBS signatures](sig/denebola.rbs) for the public API and the [changelog](CHANGELOG.md) for release history.
218
+
219
+ ## Contributing
220
+
221
+ Bug reports and pull requests are welcome at https://github.com/noxdea/denebola.
117
222
 
118
223
  ## License
119
224
 
120
- [MIT](LICENSE.txt).
225
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,572 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Denebola
4
+ # A file-backed text rope. File pages and indexes are populated on demand;
5
+ # edits replace only the affected byte ranges with ordinary Rope values.
6
+ class LazyRope
7
+ DEFAULT_CHUNK_SIZE = 1_048_576
8
+ DEFAULT_CACHE_CHUNKS = 8
9
+ FilePiece = Struct.new(:offset, :bytesize, keyword_init: true)
10
+ TextPiece = Struct.new(:rope, keyword_init: true) do
11
+ def bytesize = rope.bytesize
12
+ end
13
+
14
+ attr_reader :chunk_size, :cached_bytes
15
+
16
+ def self.open(path, chunk_size: DEFAULT_CHUNK_SIZE, encoding: Encoding::UTF_8, cache_chunks: DEFAULT_CACHE_CHUNKS)
17
+ new(path, chunk_size: chunk_size, encoding: encoding, cache_chunks: cache_chunks)
18
+ end
19
+
20
+ def initialize(path, chunk_size: DEFAULT_CHUNK_SIZE, encoding: Encoding::UTF_8, cache_chunks: DEFAULT_CACHE_CHUNKS)
21
+ raise ArgumentError, "chunk_size must be an integer >= 4" unless chunk_size.is_a?(Integer) && chunk_size >= 4
22
+ raise ArgumentError, "cache_chunks must be a positive integer" unless cache_chunks.is_a?(Integer) && cache_chunks.positive?
23
+ raise ArgumentError, "only UTF-8 files are supported" unless encoding == Encoding::UTF_8
24
+
25
+ flags = File::RDONLY | File::BINARY
26
+ flags |= File::SHARE_DELETE if defined?(File::SHARE_DELETE)
27
+ @file = File.open(path, flags)
28
+ @path = File.expand_path(path)
29
+ @chunk_size = chunk_size
30
+ @cache_chunks = cache_chunks
31
+ @cache = {}
32
+ @cached_bytes = 0
33
+ @positioned_reads = true
34
+ @read_lock = Mutex.new
35
+ @stamp = file_stamp(@file.stat)
36
+ @path_stamp = file_stamp(File.stat(@path))
37
+ @origin = @file.size >= 3 && raw_read(0, 3) == "\xEF\xBB\xBF".b ? 3 : 0
38
+ @source_bytesize = @file.size - @origin
39
+ @pieces = @source_bytesize.zero? ? [] : [FilePiece.new(offset: 0, bytesize: @source_bytesize).freeze]
40
+ rebuild_piece_ends
41
+ reset_index
42
+ rescue StandardError
43
+ @file&.close
44
+ raise
45
+ end
46
+
47
+ def lazy? = true
48
+ def closed? = @file.closed?
49
+
50
+ def bytesize
51
+ ensure_unchanged!
52
+ @piece_ends.last || 0
53
+ end
54
+ def empty? = bytesize.zero?
55
+
56
+ def length
57
+ index_to_eof
58
+ @indexed_characters
59
+ end
60
+
61
+ def size = length
62
+
63
+ def utf16_length
64
+ index_to_eof
65
+ @indexed_utf16
66
+ end
67
+
68
+ # Until exact indexing is requested, return an estimate based on the
69
+ # already scanned prefix. The estimate never reports fewer known lines.
70
+ def line_count(exact: false)
71
+ index_to_eof if exact
72
+ return packed_line_count if @index_complete
73
+ scan_index if @indexed_offset.zero? && bytesize.positive?
74
+
75
+ known_lines = packed_line_count + (@pending_cr ? 1 : 0)
76
+ breaks = known_lines - 1
77
+ return 1 if breaks.zero? || @indexed_offset.zero?
78
+
79
+ [known_lines, (breaks * bytesize.fdiv(@indexed_offset)).round + 1].max
80
+ end
81
+
82
+ def line_start(row)
83
+ raise RangeError, "line out of bounds" unless row.is_a?(Integer) && row >= 0
84
+ index_until_line(row)
85
+ raise RangeError, "line out of bounds" if row >= packed_line_count
86
+
87
+ unpack_line_start(row)
88
+ end
89
+
90
+ def line(row)
91
+ start = line_start(row)
92
+ index_until_line(row + 1)
93
+ ending = row + 1 < packed_line_count ? unpack_line_start(row + 1) : bytesize
94
+ read_bytes(start, ending - start).force_encoding(Encoding::UTF_8).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
95
+ end
96
+
97
+ def byteslice(offset, length = nil)
98
+ start, finish = slice_bounds(offset, length)
99
+ Rope.new(read_bytes(start, finish - start).force_encoding(Encoding::UTF_8))
100
+ end
101
+
102
+ def each_chunk
103
+ return enum_for(__method__) unless block_given?
104
+
105
+ offset = 0
106
+ total = bytesize
107
+ while offset < total
108
+ raw = read_bytes(offset, [chunk_size, total - offset].min)
109
+ complete = complete_utf8_length(raw)
110
+ if complete.zero?
111
+ raw = read_bytes(offset, [chunk_size + 3, total - offset].min)
112
+ complete = complete_utf8_length(raw)
113
+ end
114
+ text = raw.byteslice(0, complete).force_encoding(Encoding::UTF_8)
115
+ raise Error, "file contains invalid UTF-8" if complete.zero? || !text.valid_encoding?
116
+ yield text
117
+ offset += complete
118
+ end
119
+ self
120
+ end
121
+
122
+ def materialize(range = nil)
123
+ range ? byteslice(range) : Rope.new(to_s)
124
+ end
125
+
126
+ def to_s
127
+ text = String.new(capacity: bytesize, encoding: Encoding::UTF_8)
128
+ each_chunk { |chunk| text << chunk }
129
+ text
130
+ end
131
+
132
+ # Mutates this file-backed view and keeps untouched ranges file-backed.
133
+ def edit(range, text)
134
+ replacement = normalize_text(text)
135
+ start, finish = byte_bounds(range)
136
+ validate_offset(start)
137
+ validate_offset(finish)
138
+
139
+ updated = pieces_for(0, start)
140
+ updated << TextPiece.new(rope: Rope.new(replacement)).freeze unless replacement.empty?
141
+ updated.concat(pieces_for(finish, bytesize))
142
+ @pieces = coalesce(updated)
143
+ rebuild_piece_ends
144
+ invalidate_index_from(start)
145
+ self
146
+ end
147
+
148
+ def insert(offset, text) = edit(offset...offset, text)
149
+ def delete(range) = edit(range, "")
150
+ def replace(range, text) = edit(range, text)
151
+
152
+ def point_at(byte_offset)
153
+ validate_offset(byte_offset)
154
+ index_to([byte_offset + 1, bytesize].min)
155
+ if byte_offset.positive? && byte_offset < bytesize && read_bytes(byte_offset - 1, 2) == "\r\n"
156
+ row = line_index_at(byte_offset + 1)
157
+ return Point.new(row, 0)
158
+ end
159
+
160
+ row = line_index_at(byte_offset)
161
+ start = unpack_line_start(row)
162
+ Point.new(row, read_bytes(start, byte_offset - start).force_encoding(Encoding::UTF_8).length)
163
+ end
164
+
165
+ def offset_at(point)
166
+ start = line_start(point.row)
167
+ content = line(point.row)
168
+ raise RangeError, "column out of bounds" unless point.column.is_a?(Integer) && point.column.between?(0, content.length)
169
+
170
+ start + content.each_char.take(point.column).join.bytesize
171
+ end
172
+
173
+ def utf16_offset_at(byte_offset)
174
+ validate_offset(byte_offset)
175
+ prefix_dimensions(byte_offset).last
176
+ end
177
+
178
+ def offset_at_utf16(utf16_offset)
179
+ raise RangeError, "UTF-16 offset out of bounds" unless utf16_offset.is_a?(Integer) && utf16_offset >= 0
180
+ index_to_eof
181
+ raise RangeError, "UTF-16 offset out of bounds" if utf16_offset > @indexed_utf16
182
+
183
+ checkpoint = @checkpoints.bsearch { |entry| entry[2] > utf16_offset }
184
+ checkpoint = checkpoint ? @checkpoints[@checkpoints.index(checkpoint) - 1] : @checkpoints.last
185
+ offset, _characters, units = checkpoint
186
+ return offset if units == utf16_offset
187
+
188
+ each_codepoint_from(offset) do |codepoint, byte_start, _byte_finish|
189
+ width = codepoint > 0xFFFF ? 2 : 1
190
+ raise RangeError, "UTF-16 offset splits a surrogate pair" if units + width > utf16_offset
191
+ units += width
192
+ return _byte_finish if units == utf16_offset
193
+ break if units > utf16_offset
194
+ end
195
+ raise RangeError, "UTF-16 offset out of bounds"
196
+ end
197
+
198
+ def utf16_point_at(byte_offset)
199
+ point = point_at(byte_offset)
200
+ start = line_start(point.row)
201
+ Point.new(point.row, [utf16_offset_at(byte_offset) - utf16_offset_at(start), 0].max)
202
+ end
203
+
204
+ def offset_at_utf16_point(point)
205
+ start = line_start(point.row)
206
+ content = line(point.row)
207
+ units = 0
208
+ bytes = 0
209
+ content.each_codepoint do |codepoint|
210
+ return start + bytes if units == point.column
211
+ width = codepoint > 0xFFFF ? 2 : 1
212
+ raise RangeError, "UTF-16 column splits a surrogate pair" if units + width > point.column
213
+ units += width
214
+ bytes += codepoint.chr(Encoding::UTF_8).bytesize
215
+ end
216
+ return start + bytes if units == point.column
217
+
218
+ raise RangeError, "column out of bounds"
219
+ end
220
+
221
+ def anchor(byte_offset, bias: :right)
222
+ validate_offset(byte_offset)
223
+ Anchor.new(byte_offset, bias: bias)
224
+ end
225
+
226
+ def check_invariants!
227
+ ensure_unchanged!
228
+ raise Error, "invalid piece index" unless @piece_ends.each_cons(2).all? { |left, right| left < right }
229
+ raise Error, "invalid piece size" unless @pieces.all? { |piece| piece.bytesize.positive? }
230
+ @pieces.grep(TextPiece).each { |piece| piece.rope.check_invariants! }
231
+ index_to_eof
232
+ starts = @line_starts.unpack("Q<*")
233
+ raise Error, "invalid line index" unless starts.first == 0 && starts.each_cons(2).all? { |left, right| left < right } && starts.last <= bytesize
234
+ raise Error, "invalid dimension index" unless @checkpoints.each_cons(2).all? { |left, right| left[0] < right[0] }
235
+ true
236
+ end
237
+
238
+ def close
239
+ @file.close unless @file.closed?
240
+ nil
241
+ end
242
+
243
+ private
244
+
245
+ def normalize_text(text)
246
+ raise TypeError, "text must be a String" unless text.is_a?(String)
247
+ value = text.encoding == Encoding::UTF_8 ? text.dup : text.encode(Encoding::UTF_8)
248
+ raise ArgumentError, "text must be valid UTF-8" unless value.valid_encoding?
249
+ value
250
+ end
251
+
252
+ def byte_bounds(range)
253
+ raise TypeError, "range must be a Range" unless range.is_a?(Range)
254
+ start = range.begin || 0
255
+ finish = range.end || bytesize
256
+ finish += 1 unless range.exclude_end? || range.end.nil?
257
+ raise RangeError, "range out of bounds" unless start.is_a?(Integer) && finish.is_a?(Integer) && start.between?(0, bytesize) && finish.between?(start, bytesize)
258
+ [start, finish]
259
+ end
260
+
261
+ def slice_bounds(offset, length)
262
+ return byte_bounds(offset) if offset.is_a?(Range)
263
+ raise TypeError, "offset must be an Integer" unless offset.is_a?(Integer)
264
+ length ||= bytesize - offset
265
+ raise RangeError, "slice out of bounds" unless length.is_a?(Integer) && length >= 0 && offset.between?(0, bytesize) && offset + length <= bytesize
266
+ validate_offset(offset)
267
+ validate_offset(offset + length)
268
+ [offset, offset + length]
269
+ end
270
+
271
+ def validate_offset(offset)
272
+ raise RangeError, "offset out of bounds" unless offset.is_a?(Integer) && offset.between?(0, bytesize)
273
+ return if offset == bytesize || (read_bytes(offset, 1).getbyte(0) & 0xC0) != 0x80
274
+ raise RangeError, "offset inside UTF-8 character"
275
+ end
276
+
277
+ def pieces_for(start, finish)
278
+ return [] if start == finish
279
+ result = []
280
+ piece_start = 0
281
+ @pieces.each do |piece|
282
+ piece_finish = piece_start + piece.bytesize
283
+ overlap_start = [start, piece_start].max
284
+ overlap_finish = [finish, piece_finish].min
285
+ result << slice_piece(piece, overlap_start - piece_start, overlap_finish - overlap_start) if overlap_start < overlap_finish
286
+ piece_start = piece_finish
287
+ end
288
+ result
289
+ end
290
+
291
+ def slice_piece(piece, local_start, length)
292
+ if piece.is_a?(FilePiece)
293
+ FilePiece.new(offset: piece.offset + local_start, bytesize: length).freeze
294
+ else
295
+ TextPiece.new(rope: piece.rope.byteslice(local_start, length)).freeze
296
+ end
297
+ end
298
+
299
+ def coalesce(pieces)
300
+ pieces.each_with_object([]) do |piece, result|
301
+ previous = result.last
302
+ if previous.is_a?(FilePiece) && piece.is_a?(FilePiece) && previous.offset + previous.bytesize == piece.offset
303
+ result[-1] = FilePiece.new(offset: previous.offset, bytesize: previous.bytesize + piece.bytesize).freeze
304
+ elsif previous.is_a?(TextPiece) && piece.is_a?(TextPiece) && previous.bytesize + piece.bytesize <= Rope::DEFAULT_CHUNK_SIZE
305
+ result[-1] = TextPiece.new(rope: Rope.new(previous.rope.to_s + piece.rope.to_s)).freeze
306
+ else
307
+ result << piece
308
+ end
309
+ end
310
+ end
311
+
312
+ def rebuild_piece_ends
313
+ total = 0
314
+ @piece_ends = @pieces.map { |piece| total += piece.bytesize }
315
+ end
316
+
317
+ def read_bytes(offset, count)
318
+ ensure_unchanged!
319
+ return "".b if count.zero?
320
+ raise RangeError, "read out of bounds" unless offset >= 0 && count >= 0 && offset + count <= (@piece_ends.last || 0)
321
+
322
+ output = String.new(capacity: count, encoding: Encoding::BINARY)
323
+ piece_index = @piece_ends.bsearch_index { |piece_end| piece_end > offset } || @pieces.length
324
+ piece_start = piece_index.zero? ? 0 : @piece_ends[piece_index - 1]
325
+ while piece_index < @pieces.length
326
+ piece = @pieces[piece_index]
327
+ piece_finish = piece_start + piece.bytesize
328
+ if offset < piece_finish && offset + count > piece_start
329
+ local_start = [offset - piece_start, 0].max
330
+ length = [[offset + count, piece_finish].min - (piece_start + local_start), 0].max
331
+ if piece.is_a?(FilePiece)
332
+ output << read_file(piece.offset + local_start, length)
333
+ else
334
+ append_rope_bytes(output, piece.rope, local_start, length)
335
+ end
336
+ end
337
+ break if output.bytesize == count
338
+ piece_start = piece_finish
339
+ piece_index += 1
340
+ end
341
+ raise Error, "short read" unless output.bytesize == count
342
+ output
343
+ end
344
+
345
+ def append_rope_bytes(output, rope, offset, count)
346
+ tree = rope.__send__(:tree)
347
+ index, chunk, prefix = tree.locate(offset, :bytesize)
348
+ local = offset - prefix.bytesize
349
+ while chunk && count.positive?
350
+ length = [count, chunk.text.bytesize - local].min
351
+ output << chunk.text.b.byteslice(local, length)
352
+ count -= length
353
+ local = 0
354
+ index += 1
355
+ chunk = tree[index]
356
+ end
357
+ end
358
+
359
+ def read_file(offset, count)
360
+ output = String.new(capacity: count, encoding: Encoding::BINARY)
361
+ while count.positive?
362
+ page, local = offset.divmod(chunk_size)
363
+ data = @cache.delete(page)
364
+ unless data
365
+ data = raw_read(@origin + page * chunk_size, [chunk_size, @source_bytesize - page * chunk_size].min)
366
+ @cached_bytes += data.bytesize
367
+ while @cache.length >= @cache_chunks
368
+ _old_page, old_data = @cache.shift
369
+ @cached_bytes -= old_data.bytesize
370
+ end
371
+ end
372
+ @cache[page] = data
373
+ length = [count, data.bytesize - local].min
374
+ raise Error, "file was truncated" unless length.positive?
375
+ output << data.byteslice(local, length)
376
+ offset += length
377
+ count -= length
378
+ end
379
+ output
380
+ end
381
+
382
+ def raw_read(offset, count)
383
+ return "".b if count.zero?
384
+ if @positioned_reads
385
+ @file.pread(count, offset)
386
+ else
387
+ @read_lock.synchronize do
388
+ @file.seek(offset)
389
+ @file.read(count)
390
+ end
391
+ end
392
+ rescue NotImplementedError
393
+ @positioned_reads = false
394
+ retry
395
+ end
396
+
397
+ def reset_index
398
+ @line_starts = [0].pack("Q<")
399
+ @indexed_offset = 0
400
+ @indexed_characters = 0
401
+ @indexed_utf16 = 0
402
+ @pending_cr = nil
403
+ @checkpoints = [[0, 0, 0, nil]]
404
+ @index_complete = false
405
+ end
406
+
407
+ def invalidate_index_from(offset)
408
+ # Re-scan the byte before an edit as well: inserting or removing an LF
409
+ # can change whether a preceding CR is one terminator or half of CRLF.
410
+ rewind_to = [offset - 1, 0].max
411
+ checkpoint_index = @checkpoints.rindex { |entry| entry[0] <= rewind_to } || 0
412
+ checkpoint = @checkpoints[checkpoint_index]
413
+ @checkpoints = @checkpoints.take(checkpoint_index + 1)
414
+ @indexed_offset, @indexed_characters, @indexed_utf16, @pending_cr = checkpoint
415
+ starts = @line_starts.unpack("Q<*").take_while { |line_start| line_start <= @indexed_offset }
416
+ @line_starts = starts.pack("Q<*")
417
+ @index_complete = false
418
+ end
419
+
420
+ def index_until_line(row)
421
+ scan_index while packed_line_count <= row && !@index_complete
422
+ end
423
+
424
+ def index_to(offset)
425
+ scan_index while @indexed_offset < offset && !@index_complete
426
+ end
427
+
428
+ def index_to_eof
429
+ scan_index until @index_complete
430
+ end
431
+
432
+ def scan_index
433
+ total = bytesize
434
+ if @indexed_offset == total
435
+ append_line_start(@pending_cr) if @pending_cr
436
+ @pending_cr = nil
437
+ @index_complete = true
438
+ @checkpoints[-1] = [@indexed_offset, @indexed_characters, @indexed_utf16, nil]
439
+ return
440
+ end
441
+
442
+ raw = read_bytes(@indexed_offset, [chunk_size, total - @indexed_offset].min)
443
+ complete = complete_utf8_length(raw)
444
+ text = raw.byteslice(0, complete).force_encoding(Encoding::UTF_8)
445
+ raise Error, "file contains invalid UTF-8" if complete.zero? || !text.valid_encoding?
446
+
447
+ if text.ascii_only?
448
+ scan_ascii_index(text)
449
+ else
450
+ scan_unicode_index(text)
451
+ end
452
+ @indexed_offset += complete
453
+ @checkpoints << [@indexed_offset, @indexed_characters, @indexed_utf16, @pending_cr]
454
+ scan_index if @indexed_offset == total
455
+ end
456
+
457
+ def append_line_start(offset)
458
+ return if unpack_line_start(packed_line_count - 1) == offset
459
+ @line_starts << [offset].pack("Q<")
460
+ end
461
+
462
+ def packed_line_count = @line_starts.bytesize / 8
463
+ def unpack_line_start(row) = @line_starts.unpack1("Q<", offset: row * 8)
464
+
465
+ def line_index_at(offset)
466
+ low = 0
467
+ high = packed_line_count
468
+ while low < high
469
+ middle = (low + high) / 2
470
+ unpack_line_start(middle) <= offset ? low = middle + 1 : high = middle
471
+ end
472
+ [low - 1, 0].max
473
+ end
474
+
475
+ def prefix_dimensions(offset)
476
+ index_to(offset)
477
+ checkpoint_index = @checkpoints.bsearch_index { |entry| entry[0] > offset }
478
+ checkpoint = checkpoint_index ? @checkpoints[checkpoint_index - 1] : @checkpoints.last
479
+ base, characters, units = checkpoint
480
+ text = read_bytes(base, offset - base).force_encoding(Encoding::UTF_8)
481
+ raise RangeError, "offset inside UTF-8 character" unless text.valid_encoding?
482
+ [characters + text.length, units + (text.ascii_only? ? text.bytesize : text.encode(Encoding::UTF_16LE).bytesize / 2)]
483
+ end
484
+
485
+ def each_codepoint_from(offset)
486
+ while offset < bytesize
487
+ raw = read_bytes(offset, [chunk_size, bytesize - offset].min)
488
+ complete = complete_utf8_length(raw)
489
+ text = raw.byteslice(0, complete).force_encoding(Encoding::UTF_8)
490
+ raise Error, "file contains invalid UTF-8" if complete.zero? || !text.valid_encoding?
491
+ cursor = offset
492
+ text.each_codepoint do |codepoint|
493
+ finish = cursor + codepoint.chr(Encoding::UTF_8).bytesize
494
+ yield codepoint, cursor, finish
495
+ cursor = finish
496
+ end
497
+ offset += complete
498
+ end
499
+ end
500
+
501
+ def scan_ascii_index(text)
502
+ base = @indexed_offset
503
+ @indexed_characters += text.bytesize
504
+ @indexed_utf16 += text.bytesize
505
+ scan_from = 0
506
+ if @pending_cr
507
+ if text.start_with?("\n")
508
+ append_line_start(base + 1)
509
+ scan_from = 1
510
+ else
511
+ append_line_start(@pending_cr)
512
+ end
513
+ @pending_cr = nil
514
+ end
515
+ text.b.byteslice(scan_from, text.bytesize - scan_from).scan(/\r\n|[\r\n]/n) do |ending|
516
+ finish = base + scan_from + Regexp.last_match.end(0)
517
+ if ending == "\r" && finish == base + text.bytesize
518
+ @pending_cr = finish
519
+ else
520
+ append_line_start(finish)
521
+ end
522
+ end
523
+ end
524
+
525
+ def scan_unicode_index(text)
526
+ cursor = @indexed_offset
527
+ text.each_codepoint do |codepoint|
528
+ width = codepoint.chr(Encoding::UTF_8).bytesize
529
+ finish = cursor + width
530
+ @indexed_characters += 1
531
+ @indexed_utf16 += codepoint > 0xFFFF ? 2 : 1
532
+ if @pending_cr
533
+ if codepoint == 10
534
+ append_line_start(finish)
535
+ @pending_cr = nil
536
+ cursor = finish
537
+ next
538
+ end
539
+ append_line_start(@pending_cr)
540
+ @pending_cr = nil
541
+ end
542
+ if codepoint == 13
543
+ @pending_cr = finish
544
+ elsif codepoint == 10 || codepoint == 0x2028 || codepoint == 0x2029
545
+ append_line_start(finish)
546
+ end
547
+ cursor = finish
548
+ end
549
+ end
550
+
551
+ def complete_utf8_length(value)
552
+ return 0 if value.empty?
553
+ index = value.bytesize - 1
554
+ index -= 1 while index.positive? && (value.getbyte(index) & 0xC0) == 0x80
555
+ leading = value.getbyte(index)
556
+ expected = leading < 0x80 ? 1 : leading < 0xE0 ? 2 : leading < 0xF0 ? 3 : 4
557
+ value.bytesize - index < expected ? index : value.bytesize
558
+ end
559
+
560
+ def file_stamp(stat)
561
+ [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
562
+ end
563
+
564
+ def ensure_unchanged!
565
+ raise IOError, "closed file" if @file.closed?
566
+ unchanged = file_stamp(@file.stat) == @stamp && file_stamp(File.stat(@path)) == @path_stamp
567
+ raise Error, "file changed on disk; reopen it" unless unchanged
568
+ rescue Errno::ENOENT
569
+ raise Error, "file changed on disk; reopen it"
570
+ end
571
+ end
572
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Denebola
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/denebola.rb CHANGED
@@ -9,3 +9,4 @@ require_relative "denebola/point"
9
9
  require_relative "denebola/dimensions"
10
10
  require_relative "denebola/rope"
11
11
  require_relative "denebola/anchor"
12
+ require_relative "denebola/lazy_rope"
data/sig/denebola.rbs CHANGED
@@ -146,6 +146,43 @@ module Denebola
146
146
  end
147
147
  end
148
148
 
149
+ class LazyRope
150
+ DEFAULT_CHUNK_SIZE: Integer
151
+ DEFAULT_CACHE_CHUNKS: Integer
152
+ attr_reader chunk_size: Integer
153
+ attr_reader cached_bytes: Integer
154
+ def self.open: (String | _ToPath path, ?chunk_size: Integer, ?encoding: Encoding, ?cache_chunks: Integer) -> LazyRope
155
+ def initialize: (String | _ToPath path, ?chunk_size: Integer, ?encoding: Encoding, ?cache_chunks: Integer) -> void
156
+ def lazy?: () -> true
157
+ def closed?: () -> bool
158
+ def bytesize: () -> Integer
159
+ def size: () -> Integer
160
+ def empty?: () -> bool
161
+ def length: () -> Integer
162
+ def utf16_length: () -> Integer
163
+ def line_count: (?exact: bool) -> Integer
164
+ def line_start: (Integer) -> Integer
165
+ def line: (Integer) -> String
166
+ def byteslice: (Integer | Range[Integer?], ?Integer?) -> Rope
167
+ def each_chunk: () { (String) -> void } -> self
168
+ | () -> Enumerator[String, self]
169
+ def materialize: (?Range[Integer?]?) -> Rope
170
+ def to_s: () -> String
171
+ def edit: (Range[Integer?], String) -> self
172
+ def insert: (Integer, String) -> self
173
+ def delete: (Range[Integer?]) -> self
174
+ def replace: (Range[Integer?], String) -> self
175
+ def point_at: (Integer) -> Point
176
+ def offset_at: (Point) -> Integer
177
+ def utf16_offset_at: (Integer) -> Integer
178
+ def offset_at_utf16: (Integer) -> Integer
179
+ def utf16_point_at: (Integer) -> Point
180
+ def offset_at_utf16_point: (Point) -> Integer
181
+ def anchor: (Integer, ?bias: :left | :right) -> Anchor
182
+ def check_invariants!: () -> true
183
+ def close: () -> nil
184
+ end
185
+
149
186
  class Anchor
150
187
  attr_reader offset: Integer
151
188
  attr_reader bias: :left | :right
metadata CHANGED
@@ -1,16 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: denebola
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
11
12
  dependencies: []
12
- description: Immutable, structurally shared trees with summary dimensions, UTF-8 text
13
- editing, line and UTF-16 indexing, and anchors.
13
+ description: Immutable text ropes plus bounded-memory, file-backed editing with line
14
+ and UTF-16 indexing.
14
15
  email:
15
16
  - t.yudai92@gmail.com
16
17
  executables: []
@@ -25,6 +26,7 @@ files:
25
26
  - lib/denebola/cursor.rb
26
27
  - lib/denebola/dimensions.rb
27
28
  - lib/denebola/error.rb
29
+ - lib/denebola/lazy_rope.rb
28
30
  - lib/denebola/point.rb
29
31
  - lib/denebola/rope.rb
30
32
  - lib/denebola/text_summary.rb
@@ -38,6 +40,7 @@ metadata:
38
40
  allowed_push_host: https://rubygems.org
39
41
  source_code_uri: https://github.com/noxdea/denebola
40
42
  rubygems_mfa_required: 'true'
43
+ post_install_message:
41
44
  rdoc_options: []
42
45
  require_paths:
43
46
  - lib
@@ -52,7 +55,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
55
  - !ruby/object:Gem::Version
53
56
  version: '0'
54
57
  requirements: []
55
- rubygems_version: 4.0.19
58
+ rubygems_version: 3.4.19
59
+ signing_key:
56
60
  specification_version: 4
57
61
  summary: Persistent summary B+ trees and Unicode text ropes in pure Ruby
58
62
  test_files: []