denebola 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28307dc01292f1215dcfb5466ba9fe182633346c622d69d336456983c00276e2
4
- data.tar.gz: 24520e65a734fb920588210d4d9b829ef0ec0997178e0ab88b708d49af604ad1
3
+ metadata.gz: f055c7610d7665e51b0f089b0d408584ed42eef42cc4511c9a18d46887618366
4
+ data.tar.gz: ced4c1c876d1e11e55089c61ce07a13679d0c53ec61a1734a8d85cb77447fd48
5
5
  SHA512:
6
- metadata.gz: 8a29c867cdb4fa7f3e7d74c27f7556d8ecb50c08706f814d22c9ff43c90dfa776c4a4a502969a3708c67bdb9bccb8f6149c09d3656105ea97b3e925aaa378519
7
- data.tar.gz: 36f141d074afb7bce936876e301df6d1884881c9806803619fea3d9f20f86aa0627c2ef5baaeb6c7bb5ea23041748d72c9d2891084bc9c92fce65f0ecd094edf
6
+ metadata.gz: 83906a37466f697707ec32ef73b4ef3baf60a33ac95b757a18b463139593a72b6d16fa68d033f6f5caba82720f170f192344f25f78836ac298d4b750772a277e
7
+ data.tar.gz: 923e8951c8cd9621b2c194be56a40b8d5fa548ecf7bd5cef17c0b4f2449b7e4ffeeee2cda26baef2f313ad0af79a8e37905bb97b668b5a550f426bb5e4a467c8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.2.1 — 2026-09-16
4
10
 
5
11
  - Keep same-offset batch edit ordering deterministic across ropes and anchors.
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 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.
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
 
@@ -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? = @file.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
- @file.close unless @file.closed?
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 @file.closed?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Denebola
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
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.1
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-16 00:00:00.000000000 Z
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.