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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 605dec1949038f3bf81ca5b49d875231c515fdc656933467f2d34d1b1411da7e
4
- data.tar.gz: 856f7ba61ad06df9f280850a4912259d1a76556cf32e0e5e8b8c78e6cec264e5
3
+ metadata.gz: f055c7610d7665e51b0f089b0d408584ed42eef42cc4511c9a18d46887618366
4
+ data.tar.gz: ced4c1c876d1e11e55089c61ce07a13679d0c53ec61a1734a8d85cb77447fd48
5
5
  SHA512:
6
- metadata.gz: 02af176392f474c79428f0837f7ea44ed7198a25a56a250d6119b80ddc9a9bf6f5e5eba7e020417c3c3b922ec65912b075050e6224993b73e78f4da1c8cf1d79
7
- data.tar.gz: 2d69fa29d55fb1970b30da016df0cc4730985ec286aaaa2048c08f17472f38e3904d37cefaa75961b6f3e476e1e86e3bd45fa1223dbe7101a2802dd480a2e8ed
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 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
 
@@ -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 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.
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
 
@@ -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
- edits.sort_by { |range, _| range.begin }.each do |range, text|
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 or overlapping edits" unless start.is_a?(Integer) && start >= previous && finish >= start
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
- transformed ||= start + delta + (bias == :right ? text.bytesize : 0)
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
@@ -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
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)] }.sort_by { |start, finish, _| [start, finish] }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Denebola
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
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.0
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-15 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.
@@ -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