denebola 0.2.0 → 0.2.2
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 +11 -0
- data/README.md +2 -2
- data/lib/denebola/anchor.rb +12 -3
- data/lib/denebola/edit.rb +17 -0
- data/lib/denebola/lazy_rope.rb +77 -4
- data/lib/denebola/rope.rb +5 -10
- data/lib/denebola/version.rb +1 -1
- data/lib/denebola.rb +1 -0
- data/sig/denebola.rbs +3 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f055c7610d7665e51b0f089b0d408584ed42eef42cc4511c9a18d46887618366
|
|
4
|
+
data.tar.gz: ced4c1c876d1e11e55089c61ce07a13679d0c53ec61a1734a8d85cb77447fd48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83906a37466f697707ec32ef73b4ef3baf60a33ac95b757a18b463139593a72b6d16fa68d033f6f5caba82720f170f192344f25f78836ac298d4b750772a277e
|
|
7
|
+
data.tar.gz: 923e8951c8cd9621b2c194be56a40b8d5fa548ecf7bd5cef17c0b4f2449b7e4ffeeee2cda26baef2f313ad0af79a8e37905bb97b668b5a550f426bb5e4a467c8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.2 — 2026-09-18
|
|
6
|
+
|
|
7
|
+
- Add persistent batched edits and bounded line-window access to `LazyRope`; related snapshots share one backing-file close lifecycle.
|
|
8
|
+
|
|
9
|
+
## 0.2.1 — 2026-09-16
|
|
10
|
+
|
|
11
|
+
- Keep same-offset batch edit ordering deterministic across ropes and anchors.
|
|
12
|
+
- Transform right-biased anchors after every insertion and replacement at their boundary.
|
|
13
|
+
|
|
3
14
|
## 0.2.0 — 2026-09-15
|
|
4
15
|
|
|
5
16
|
- Add `LazyRope` for bounded-memory access to very large UTF-8 files.
|
data/README.md
CHANGED
|
@@ -86,7 +86,7 @@ text.materialize(0...1024) # an ordinary editable Rope
|
|
|
86
86
|
text.close
|
|
87
87
|
```
|
|
88
88
|
|
|
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
|
|
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; `apply_edits` returns a persistent view while storing only replacement text in memory. Views in that snapshot family share one backing-file lifetime, so closing any view closes the family. Call `materialize` for an independent `Rope` snapshot. 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
90
|
|
|
91
91
|
## Text Rope
|
|
92
92
|
|
|
@@ -109,7 +109,7 @@ rope.apply_edits([[0...5, "Hi"], [6...11, "Ruby"]]).to_s
|
|
|
109
109
|
|
|
110
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
111
|
|
|
112
|
-
`apply_edits` applies nonoverlapping edits against one original snapshot and
|
|
112
|
+
`apply_edits` applies nonoverlapping edits against one original snapshot, and `Anchor#transform` uses the same source order. Adjacent ranges are allowed; zero-length insertions at the same offset keep input order and precede a replacement beginning there. Input strings are copied or shared safely with Ruby's copy-on-write strings, so later mutation of the source cannot alter a rope.
|
|
113
113
|
|
|
114
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
115
|
|
data/lib/denebola/anchor.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "edit"
|
|
4
|
+
|
|
3
5
|
module Denebola
|
|
4
6
|
# A position transformed explicitly by edits against its current snapshot.
|
|
5
7
|
class Anchor
|
|
@@ -15,15 +17,22 @@ module Denebola
|
|
|
15
17
|
delta = 0
|
|
16
18
|
previous = 0
|
|
17
19
|
transformed = nil
|
|
18
|
-
|
|
20
|
+
normalized = edits.map do |range, text|
|
|
21
|
+
raise TypeError, "expected a Range of byte offsets" unless range.is_a?(Range)
|
|
19
22
|
start = range.begin
|
|
20
23
|
finish = range.end + (range.exclude_end? ? 0 : 1)
|
|
21
|
-
raise ArgumentError, "invalid
|
|
24
|
+
raise ArgumentError, "invalid edit" unless start.is_a?(Integer) && finish.is_a?(Integer) && start >= 0 && finish >= start
|
|
25
|
+
[start, finish, Edit.normalize_text(text)]
|
|
26
|
+
end
|
|
27
|
+
Edit.sort(normalized).each do |start, finish, text|
|
|
28
|
+
raise ArgumentError, "invalid or overlapping edits" if start < previous
|
|
22
29
|
previous = finish
|
|
23
30
|
if offset < start
|
|
24
31
|
transformed ||= offset + delta
|
|
25
32
|
elsif offset <= finish
|
|
26
|
-
|
|
33
|
+
position = start + delta
|
|
34
|
+
transformed = position + text.bytesize if bias == :right
|
|
35
|
+
transformed ||= position
|
|
27
36
|
end
|
|
28
37
|
delta += text.bytesize - (finish - start)
|
|
29
38
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
module Edit
|
|
5
|
+
def self.sort(edits)
|
|
6
|
+
edits.each_with_index.sort_by { |edit, index| [edit[0], edit[1], index] }.map!(&:first)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.normalize_text(text)
|
|
10
|
+
raise TypeError, "text must be a String" unless text.is_a?(String)
|
|
11
|
+
string = text.encoding == Encoding::UTF_8 ? text : text.encode(Encoding::UTF_8)
|
|
12
|
+
raise ArgumentError, "text must be valid UTF-8" unless string.valid_encoding?
|
|
13
|
+
string
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
private_constant :Edit
|
|
17
|
+
end
|
data/lib/denebola/lazy_rope.rb
CHANGED
|
@@ -10,6 +10,20 @@ module Denebola
|
|
|
10
10
|
TextPiece = Struct.new(:rope, keyword_init: true) do
|
|
11
11
|
def bytesize = rope.bytesize
|
|
12
12
|
end
|
|
13
|
+
class FileOwner
|
|
14
|
+
def initialize(file)
|
|
15
|
+
@file = file
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def closed? = @file.closed?
|
|
20
|
+
|
|
21
|
+
def close
|
|
22
|
+
@lock.synchronize { @file.close unless @file.closed? }
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
private_constant :FileOwner
|
|
13
27
|
|
|
14
28
|
attr_reader :chunk_size, :cached_bytes
|
|
15
29
|
|
|
@@ -25,6 +39,7 @@ module Denebola
|
|
|
25
39
|
flags = File::RDONLY | File::BINARY
|
|
26
40
|
flags |= File::SHARE_DELETE if defined?(File::SHARE_DELETE)
|
|
27
41
|
@file = File.open(path, flags)
|
|
42
|
+
@file_owner = FileOwner.new(@file)
|
|
28
43
|
@path = File.expand_path(path)
|
|
29
44
|
@chunk_size = chunk_size
|
|
30
45
|
@cache_chunks = cache_chunks
|
|
@@ -45,7 +60,7 @@ module Denebola
|
|
|
45
60
|
end
|
|
46
61
|
|
|
47
62
|
def lazy? = true
|
|
48
|
-
def closed? = @
|
|
63
|
+
def closed? = @file_owner.closed?
|
|
49
64
|
|
|
50
65
|
def bytesize
|
|
51
66
|
ensure_unchanged!
|
|
@@ -94,6 +109,26 @@ module Denebola
|
|
|
94
109
|
read_bytes(start, ending - start).force_encoding(Encoding::UTF_8).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
95
110
|
end
|
|
96
111
|
|
|
112
|
+
def line_end(row)
|
|
113
|
+
start = line_start(row)
|
|
114
|
+
index_until_line(row + 1)
|
|
115
|
+
ending = row + 1 < packed_line_count ? unpack_line_start(row + 1) : bytesize
|
|
116
|
+
tail = read_bytes([ending - 3, start].max, [ending - start, 3].min)
|
|
117
|
+
ending - (tail.end_with?("\r\n") ? 2 : tail.end_with?("\xE2\x80\xA8".b, "\xE2\x80\xA9".b) ? 3 : tail.end_with?("\r", "\n") ? 1 : 0)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def line_window(row, from: 0, max_bytes: 16_384)
|
|
121
|
+
raise TypeError, "from must be an Integer" unless from.is_a?(Integer)
|
|
122
|
+
raise ArgumentError, "max_bytes must be a nonnegative integer" unless max_bytes.is_a?(Integer) && max_bytes >= 0
|
|
123
|
+
|
|
124
|
+
start, ending = line_start(row), line_end(row)
|
|
125
|
+
offset = (start + from).clamp(start, ending)
|
|
126
|
+
offset -= 1 while offset > start && offset < ending && (read_bytes(offset, 1).getbyte(0) & 0xC0) == 0x80
|
|
127
|
+
value = read_bytes(offset, [max_bytes, ending - offset].min)
|
|
128
|
+
value = value.byteslice(0, complete_utf8_length(value)) unless value.empty?
|
|
129
|
+
[value.force_encoding(Encoding::UTF_8), offset - start]
|
|
130
|
+
end
|
|
131
|
+
|
|
97
132
|
def byteslice(offset, length = nil)
|
|
98
133
|
start, finish = slice_bounds(offset, length)
|
|
99
134
|
Rope.new(read_bytes(start, finish - start).force_encoding(Encoding::UTF_8))
|
|
@@ -149,6 +184,34 @@ module Denebola
|
|
|
149
184
|
def delete(range) = edit(range, "")
|
|
150
185
|
def replace(range, text) = edit(range, text)
|
|
151
186
|
|
|
187
|
+
# Ranges address the original snapshot. The returned view has independent
|
|
188
|
+
# overlays, indexes, and cache. Snapshots share one backing-file lifetime:
|
|
189
|
+
# closing any snapshot closes the complete family.
|
|
190
|
+
def apply_edits(edits)
|
|
191
|
+
normalized = Edit.sort(edits.map do |range, text|
|
|
192
|
+
start, finish = byte_bounds(range)
|
|
193
|
+
validate_offset(start)
|
|
194
|
+
validate_offset(finish)
|
|
195
|
+
[start, finish, normalize_text(text)]
|
|
196
|
+
end)
|
|
197
|
+
previous = 0
|
|
198
|
+
normalized.each do |start, finish, _text|
|
|
199
|
+
raise ArgumentError, "overlapping edits" if start < previous
|
|
200
|
+
previous = finish
|
|
201
|
+
end
|
|
202
|
+
return self if normalized.empty?
|
|
203
|
+
|
|
204
|
+
updated = []
|
|
205
|
+
consumed = 0
|
|
206
|
+
normalized.each do |start, finish, text|
|
|
207
|
+
updated.concat(pieces_for(consumed, start))
|
|
208
|
+
updated << TextPiece.new(rope: Rope.new(text)).freeze unless text.empty?
|
|
209
|
+
consumed = finish
|
|
210
|
+
end
|
|
211
|
+
updated.concat(pieces_for(consumed, bytesize))
|
|
212
|
+
snapshot_with(coalesce(updated))
|
|
213
|
+
end
|
|
214
|
+
|
|
152
215
|
def point_at(byte_offset)
|
|
153
216
|
validate_offset(byte_offset)
|
|
154
217
|
index_to([byte_offset + 1, bytesize].min)
|
|
@@ -236,8 +299,7 @@ module Denebola
|
|
|
236
299
|
end
|
|
237
300
|
|
|
238
301
|
def close
|
|
239
|
-
@
|
|
240
|
-
nil
|
|
302
|
+
@file_owner.close
|
|
241
303
|
end
|
|
242
304
|
|
|
243
305
|
private
|
|
@@ -314,6 +376,17 @@ module Denebola
|
|
|
314
376
|
@piece_ends = @pieces.map { |piece| total += piece.bytesize }
|
|
315
377
|
end
|
|
316
378
|
|
|
379
|
+
def snapshot_with(pieces)
|
|
380
|
+
ensure_unchanged!
|
|
381
|
+
snapshot = dup
|
|
382
|
+
snapshot.instance_variable_set(:@cache, {})
|
|
383
|
+
snapshot.instance_variable_set(:@cached_bytes, 0)
|
|
384
|
+
snapshot.instance_variable_set(:@pieces, pieces.freeze)
|
|
385
|
+
snapshot.__send__(:rebuild_piece_ends)
|
|
386
|
+
snapshot.__send__(:reset_index)
|
|
387
|
+
snapshot
|
|
388
|
+
end
|
|
389
|
+
|
|
317
390
|
def read_bytes(offset, count)
|
|
318
391
|
ensure_unchanged!
|
|
319
392
|
return "".b if count.zero?
|
|
@@ -562,7 +635,7 @@ module Denebola
|
|
|
562
635
|
end
|
|
563
636
|
|
|
564
637
|
def ensure_unchanged!
|
|
565
|
-
raise IOError, "closed file" if
|
|
638
|
+
raise IOError, "closed file" if closed?
|
|
566
639
|
unchanged = file_stamp(@file.stat) == @stamp && file_stamp(File.stat(@path)) == @path_stamp
|
|
567
640
|
raise Error, "file changed on disk; reopen it" unless unchanged
|
|
568
641
|
rescue Errno::ENOENT
|
data/lib/denebola/rope.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "edit"
|
|
4
|
+
|
|
3
5
|
module Denebola
|
|
4
6
|
class Rope
|
|
5
7
|
DEFAULT_CHUNK_SIZE = 1024
|
|
@@ -22,7 +24,7 @@ module Denebola
|
|
|
22
24
|
def initialize(text = "", chunk_size: DEFAULT_CHUNK_SIZE, branching: Tree::DEFAULT_BRANCHING, tree: nil)
|
|
23
25
|
raise ArgumentError, "chunk_size must be an integer >= 4" unless chunk_size.is_a?(Integer) && chunk_size >= 4
|
|
24
26
|
@chunk_size = chunk_size
|
|
25
|
-
@tree = tree || Tree.new(chunks(normalize_text(text)), summary: TextSummary, branching: branching)
|
|
27
|
+
@tree = tree || Tree.new(chunks(Edit.normalize_text(text)), summary: TextSummary, branching: branching)
|
|
26
28
|
freeze
|
|
27
29
|
end
|
|
28
30
|
|
|
@@ -51,7 +53,7 @@ module Denebola
|
|
|
51
53
|
|
|
52
54
|
def replace(range, text)
|
|
53
55
|
start, finish = byte_bounds(range)
|
|
54
|
-
replacement = normalize_text(text)
|
|
56
|
+
replacement = Edit.normalize_text(text)
|
|
55
57
|
first_index, first, prefix = locate_byte_offset(start)
|
|
56
58
|
last_index, last, suffix = locate_byte_offset(finish)
|
|
57
59
|
if first_index.positive? && start == prefix.bytesize
|
|
@@ -76,7 +78,7 @@ module Denebola
|
|
|
76
78
|
|
|
77
79
|
# Ranges refer to the original snapshot. Adjacent edits are allowed; overlaps are rejected.
|
|
78
80
|
def apply_edits(edits)
|
|
79
|
-
normalized = edits.map { |range, text| [*byte_bounds(range), normalize_text(text)] }
|
|
81
|
+
normalized = Edit.sort(edits.map { |range, text| [*byte_bounds(range), Edit.normalize_text(text)] })
|
|
80
82
|
previous = 0
|
|
81
83
|
normalized.each do |start, finish, _|
|
|
82
84
|
raise ArgumentError, "overlapping edits" if start < previous
|
|
@@ -234,13 +236,6 @@ module Denebola
|
|
|
234
236
|
left.slice(0, left.size - 1).append(boundary).append(right.slice(1, right.size - 1))
|
|
235
237
|
end
|
|
236
238
|
|
|
237
|
-
def normalize_text(text)
|
|
238
|
-
raise TypeError, "text must be a String" unless text.is_a?(String)
|
|
239
|
-
string = text.encoding == Encoding::UTF_8 ? text : text.encode(Encoding::UTF_8)
|
|
240
|
-
raise ArgumentError, "text must be valid UTF-8" unless string.valid_encoding?
|
|
241
|
-
string
|
|
242
|
-
end
|
|
243
|
-
|
|
244
239
|
def chunks(text)
|
|
245
240
|
return [] if text.empty?
|
|
246
241
|
return [Chunk.new(text)] if text.bytesize <= chunk_size
|
data/lib/denebola/version.rb
CHANGED
data/lib/denebola.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "denebola/cursor"
|
|
|
7
7
|
require_relative "denebola/text_summary"
|
|
8
8
|
require_relative "denebola/point"
|
|
9
9
|
require_relative "denebola/dimensions"
|
|
10
|
+
require_relative "denebola/edit"
|
|
10
11
|
require_relative "denebola/rope"
|
|
11
12
|
require_relative "denebola/anchor"
|
|
12
13
|
require_relative "denebola/lazy_rope"
|
data/sig/denebola.rbs
CHANGED
|
@@ -162,7 +162,9 @@ module Denebola
|
|
|
162
162
|
def utf16_length: () -> Integer
|
|
163
163
|
def line_count: (?exact: bool) -> Integer
|
|
164
164
|
def line_start: (Integer) -> Integer
|
|
165
|
+
def line_end: (Integer) -> Integer
|
|
165
166
|
def line: (Integer) -> String
|
|
167
|
+
def line_window: (Integer, ?from: Integer, ?max_bytes: Integer) -> [String, Integer]
|
|
166
168
|
def byteslice: (Integer | Range[Integer?], ?Integer?) -> Rope
|
|
167
169
|
def each_chunk: () { (String) -> void } -> self
|
|
168
170
|
| () -> Enumerator[String, self]
|
|
@@ -172,6 +174,7 @@ module Denebola
|
|
|
172
174
|
def insert: (Integer, String) -> self
|
|
173
175
|
def delete: (Range[Integer?]) -> self
|
|
174
176
|
def replace: (Range[Integer?], String) -> self
|
|
177
|
+
def apply_edits: (Array[[Range[Integer?], String]]) -> LazyRope
|
|
175
178
|
def point_at: (Integer) -> Point
|
|
176
179
|
def offset_at: (Point) -> Integer
|
|
177
180
|
def utf16_offset_at: (Integer) -> Integer
|
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.2.
|
|
4
|
+
version: 0.2.2
|
|
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-18 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.
|
|
@@ -25,6 +25,7 @@ files:
|
|
|
25
25
|
- lib/denebola/anchor.rb
|
|
26
26
|
- lib/denebola/cursor.rb
|
|
27
27
|
- lib/denebola/dimensions.rb
|
|
28
|
+
- lib/denebola/edit.rb
|
|
28
29
|
- lib/denebola/error.rb
|
|
29
30
|
- lib/denebola/lazy_rope.rb
|
|
30
31
|
- lib/denebola/point.rb
|